Skip to content

IO#gets: honour the global input record separator $/ (fixes #64) - #95

Open
jiro-by-nori[bot] wants to merge 6 commits into
mainfrom
jiro/issue-64-rebased
Open

jiro-by-nori[bot] wants to merge 6 commits into
mainfrom
jiro/issue-64-rebased

Conversation

@jiro-by-nori

@jiro-by-nori jiro-by-nori Bot commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

Summary

IO#gets — and therefore IO#readline and IO#each_line, which the prelude builds on it — now honours the global input record separator $/ instead of always splitting on "\n". Fixes #64.

  • $/ is read at each call: unset means CRuby's default "\n", $/ = nil reads to end of file, and a String is used verbatim as the separator (including multi-character separators).
  • The limit-only form gets(n) (and gets(nil, n)) uses $/ too, as CRuby does.
  • Paragraph mode ($/ = "") and non-String, non-nil separators gate by name rather than answering an approximated line or fabricating a TypeError.
  • The change also adds the framing lemmas defaultRecordSep_frame / getsArgsH_frame and a #guard control file pinning the separator matrix against the boot fixture /tmp/vfsdata/nums.txt ("1\n2\n3\n").

This PR is the $/ change stacked on the VFS step-2/step-5 descriptor work that it depends on, rebased onto current main. It supersedes #36 (the step-5 stack, rebased here) and #90 (the $/ change, rebased onto it). It also derives Repr for IOMode so genbootedheap builds with the descriptor payload.

Test plan

  • cd books && lake build — all 1400 targets, including the new InputRecordSeparatorControls #guards (default $/, multi-character separator, $/ = nil, paragraph-mode gate, non-String gate). Passed.
  • uv run difftest run --tier 0 --sut lean — 1287 ran, 1087 agree, 194 unsupported, 6 not compared, 0 disagree. scripts/check-conformance.sh printed CONFORMANCE OK.
  • Regression and adversarial corpora — 219 programs (218 agree, 1 unsupported) and 70 programs (57 agree, 13 unsupported), 0 disagreements.
  • books/scripts/check-soundness.sh — SOUNDNESS CHECK PASSED.
  • books/scripts/check-metatheory.sh — meta-theory builds, axioms are within propext / Classical.choice / Quot.sound, booted-heap probes pass.
  • DIFFTEST_NO_SKIPS=1 uv run pytest in difftest/ — 164 passed, no skips.
  • scripts/check-not-weakened.py — CHECKS NOT WEAKENED.
  • make gen equivalent: PreludeJson.lean, Prelude.lean, CRubyNames.lean, Unicode.lean and the audited-checker projections are all fresh.

Authored by Jiro, an autonomous agent: both the code and this description.

jiro-by-nori Bot and others added 6 commits November 7, 2026 14:19
Add descriptors to the symbolic virtual filesystem of issue #7. A file is
still a heap object; a descriptor is now one too.

- Payload.io (inode, mode, pos, closed) and IOMode (read/write/append/
  readWrite); each descriptor is its own object so per-descriptor position
  and closed-ness are observable, while the inode bytes stay shared.
- File.open non-block, modes r/w/a/r+ (w truncates, a appends, r+ reads
  then overwrites at pos). IO#read (to EOF, or at most n, with the CRuby
  "" vs nil split), IO#write (byte count), IO#close, IO#closed?, IO#flush.
- Class-object receivers keep the step-1 File.read/write and IO.read/write
  paths; .io receivers take the descriptor path.
- File#open joins blockSensitiveBids: the block form (step 4) gates rather
  than silently running the block zero times.
- Repr/Trace/nativeReprOwner gained the .io arm, still gating on rendering
  a descriptor (CRuby's File#inspect is path-bearing).
- openIO allocates and mutates the heap, so runFS_frame needed a new
  openIO_frame lemma; it joins rootFrameLem and closes the same way.

Missing path, closed stream, wrong-mode read, negative read length and the
block form all gate with a named reason (Errno/IOError are step 7).

Verified: lake build rubycore; RATCHET GREEN (run_typed_ratchet.sh, with and
without RATCHET_SKIP_AGREEMENT=1); check-isolation.sh OK; check-proofs.sh OK
(standard axioms). Model-vs-CRuby 4.0.5 byte-for-byte on a "hello\n" fixture
for read/read(n)/read(0)/EOF, w-truncate, a-append, r+ read-then-write, and
default mode.

Co-authored-by: Nori <contact@tilework.tech>
…ach_line/pos/rewind)

Step 5 of issue #7's ship order, over the step-2 descriptors. No new payload:
every operation is a position move plus a slice of the inode's bytes.

- IO#gets, all four CRuby forms: default "\n" separator, an Integer limit, a
  String separator, or both, plus gets(nil) reading to EOF. A separator runs
  through its first occurrence (whole remainder if absent), a limit truncates,
  and end of file answers nil. The surprising corner gets(0) = "" without moving
  the position, even at EOF, is modelled and pinned.
- IO#pos reads the byte position; IO#pos= seeks (negative gates on the
  unmodeled Errno::EINVAL); IO#rewind returns the position to zero and answers 0.
  A closed descriptor gates on all three.
- IO#readline and IO#each_line are prelude Ruby over gets, not primitives, so
  the block form is ordinary evaluation. readline's EOF case and the blockless
  each_line (CRuby answers an Enumerator a descriptor cannot build) gate by name
  rather than returning a guess.
- ioId's builtin row gained readline/pos/pos=/rewind; gets and each_line were
  already listed by step 2 but unhandled, so they now resolve for real.

No new proof lemma: gets/readline/pos=/rewind mutate the existing descriptor in
place, the shape IO#read already had, so runFS_frame covers them.

Verified: lake build rubycore/active-ratchet; CLINK REBUILD CHECKS PASS (125
certified, 0 gated, standard axioms); check-proofs.sh OK; check-isolation.sh OK;
all 263 corpus programs still run with 0 hard failures. Model-vs-CRuby 4.0.5
byte-for-byte over the fixture /tmp/vfsdata/nums.txt for the gets/limit/separator
matrix, pos, pos=, rewind, readline and block-form each_line. The full corpus
gate (Sorbet pipeline) cannot run here because sorbet-static is absent (issue

Authored by Nori (agent).

Co-authored-by: Nori <contact@tilework.tech>
readline and each_line are prelude Ruby over gets, but the first cut took no
arguments, so readline("\n", 5) raised ArgumentError where CRuby answers and
each_line("2") gated. Both now take *args and forward them to gets, matching
CRuby's separator/limit arguments.

Verified: lake build rubycore/active-ratchet; model-vs-CRuby 4.0.5 byte-for-byte
for readline("2") and each_line("2") over /tmp/vfsdata/nums.txt.

Authored by Nori (agent).

Co-authored-by: Nori <contact@tilework.tech>
getsArgsH's two-argument branch only accepted a String separator, so
gets(nil, 3) gated where CRuby reads the rest capped at 3. nil as the first
argument means "no separator" (read the remainder), and the limit still
applies; getsLine already applies the limit after choosing the line, so only
the argument parser needed the case.

Verified: lake build rubycore/active-ratchet; model-vs-CRuby 4.0.5 byte-for-byte
for gets(nil, 3), gets(nil, 100) and gets(nil).

Authored by Nori (agent).

Co-authored-by: Nori <contact@tilework.tech>
Co-authored-by: Jiro <contact@tilework.tech>
The step-2 descriptor payload carries an IOMode, so deriving Repr for
Payload needs an instance for it. Without this the boot-heap generator
does not build once the descriptor branch is rebased onto current main.

Co-authored-by: Jiro <contact@tilework.tech>

@jiro-approver-samxif jiro-approver-samxif Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head a76432e.

Verdict: cannot approve — 1,205 changed lines against max_changed_lines = 500. Unlike #103
and #96, a generated-file waiver alone would not unblock this one: after excluding the two
regenerated sources, the hand-authored delta is still ~527 lines.

Where the lines go

File Lines
ruby-lean/RubyCore/Generated/PreludeJson.lean +302 / −291
ruby-lean/RubyCore/Builtins/FS.lean +332 / −26
ruby-lean/RubyCore/Generated/Prelude.lean +81 / −4
books/Books/TypeSoundness/Controls/InputRecordSeparatorControls.lean +50 (new)
ruby-lean/prelude/prelude.rb +32
books/Books/Metatheory/Framing/RootFramePrimitives.lean +27
ruby-lean/RubyCore/Heap.lean +23 / −1
ruby-lean/RubyCore/Repr.lean +8 / −8
ruby-lean/RubyCore/Trace.lean +9
ruby-lean/RubyCore/Builtins/Support.lean +6 / −1
ruby-lean/GenBootedHeap.lean, ruby-lean/RubyCore/Boot.lean 2 each

Generated sources account for 678 lines; hand-authored work is 527 (the IO descriptor payload,
open/gets/pos/rewind/readline/each_line, the $/ lookup, a new framing lemma set, and
new runtime controls).

Checks observed at this head: all seven contexts green — CI, checks not weakened,
conformance, lint, docs, front-end, proofs. No unresolved review threads. No human-gated
path touched.

Validation: none run locally — no checkout or Lean toolchain in this session. In particular I did
not re-run make books for the new openIO_frame lemma or evaluate the #guard controls in
InputRecordSeparatorControls.lean; those rest on the green proofs context and the PR's reported
make metatheory / make books.

On the change itself: the $/ handling matches the stated rule — unset means "\n", nil reads
to EOF, a String is an arbitrary separator, and paragraph mode ($/ = "") plus non-String/non-nil
values gate by name instead of guessing. Reading $/ from m.globals rather than getGlobal so
"unset" and "explicitly nil" stay distinct is right. getsLine's limit = some 0 case answering
"" without moving the position is the CRuby probe result, and the controls pin it. IO/IOError
shapes gate rather than fabricate an exception object, which is rule 3. Nothing weakens a check.

What is needed (@sam-xif): this one is over the limit on its own merits, so it needs either a
human read of the FS.lean step-5 arm and the new framing lemma, or splitting — for example the
descriptor/open work (step 2) separate from the gets/$/' work (step 5). The generator reflow
issue raised on #103 applies here too but is not the binding constraint.

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.

IO#gets/readline/each_line ignore the global input record separator $/, answering wrong lines

0 participants