Skip to content

ast: record the line a block's opener sits on - #46

Merged
tannevaled merged 1 commit into
mainfrom
block-opener-line
Oct 4, 2026
Merged

tannevaled merged 1 commit into
mainfrom
block-opener-line

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

What

A Block had no line of its own. Program.Lines stamps statement nodes,
and a Block is not one — it is not even a Node, being a field of Call —
so a consumer had to take the line of whatever statement enclosed the block.
That is the same line only when the block opens on that statement's first line.

Measured

ruby 4.0.5 reports the opener's line as a block's
location.first_lineno:

h = {                      # line 1
  :a => proc {             # line 2
    1
  },
  :b => proc { 2 },        # line 5
  :c => proc {             # line 6
  },
}
h[:a].source_location[1]   # ruby 2    from the enclosing statement: 1
h[:b].source_location[1]   # ruby 5    from the enclosing statement: 1
h[:c].source_location[1]   # ruby 6    from the enclosing statement: 1

The body's first line is not a substitute, which is why this needs the
opener rather than a cheaper approximation: ruby answers 2 for :a, whose
first statement is on line 3, and :c has no body statement at all.

Shape

Block.Line, a field — not a position field on all 55 node types, and not
Program.Lines stretched to keys that are not Nodes (it cannot be: Block
does not implement the interface). 0 means "not recorded".

Three sites set it: the { of parseBraceBlock, the do of parseDoBlock,
and the -> of parseLambda — the arrow itself, since that is the line ruby
reports for an arrow lambda (measured; they coincide in all but deliberately
odd layouts).

The Lines doc comment now records this as its one deliberate exception, with
the measurement that forced it.

Why it matters downstream

It is observable as Proc#source_location, Method#source_location for a
define_method, and the line a Proc prints when it inspects itself. In rbgo
the block ISeq's FirstLine was taken from the enclosing scope's current line,
so proc { } inside any multi-line literal reported the literal's opening line
— 1, 8, 13 where ruby says 4, 10, 15. That cannot be fixed
downstream without this, because the information was not in the tree.

Green

go test ./... -count=1 exit 0 (4 packages), go vet clean, gofmt clean,
and the parser package stays at 100.0% of statements.

Five new cases, each want being ruby 4.0.5's own Proc#source_location: the
multi-line hash (the discriminating one — body-first-line would give 3, 6, 7),
a multi-line array with do…end, the arrow lambda, a single-line hash as the
control that nothing moved, and a method call whose do lands on a later line
than its receiver.

Also gofmt'd paren_singleton_def_test.go, which was unformatted on main.

🤖 Generated with Claude Code

A Block had no line of its own. Program.Lines stamps STATEMENT nodes, and a
Block is not one -- it is not even a Node, being a field of Call -- so a
consumer had to take the line of whatever statement enclosed the block. That
is the same line only when the block opens on that statement's first line.

Measured against ruby 4.0.5, which reports the opener's line as a block's
location.first_lineno:

    h = {                      # line 1
      :a => proc {             # line 2
        1
      },
      :b => proc { 2 },        # line 5
      :c => proc {             # line 6
      },
    }
    h[:a].source_location[1]   # ruby 2    from the statement, 1
    h[:b].source_location[1]   # ruby 5    from the statement, 1
    h[:c].source_location[1]   # ruby 6    from the statement, 1

The body's first line is NOT a substitute, which is why this needs the
opener and not a cheaper approximation: ruby answers 2 for :a, whose first
statement is on line 3, and :c has no body statement at all.

So Block carries a Line field rather than a position field being added to all
55 node types, and rather than Program.Lines being stretched to keys that are
not Nodes. 0 means "not recorded".

Three sites set it: the `{` of parseBraceBlock, the `do` of parseDoBlock, and
the `->` of parseLambda -- the arrow itself, since that is the line ruby
reports for an arrow lambda (measured; they coincide in all but deliberately
odd layouts).

Observable downstream as Proc#source_location, Method#source_location for a
define_method, and the line a Proc prints when it inspects itself.

Also gofmt'd paren_singleton_def_test.go, which was unformatted on main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 7c08f07 into main Oct 4, 2026
11 checks passed
@tannevaled
tannevaled deleted the block-opener-line branch October 4, 2026 13:16
tannevaled added a commit to go-embedded-ruby/ruby that referenced this pull request Oct 4, 2026
…statement's (#763)

A block ISeq's FirstLine is MRI's location.first_lineno -- the line the block's
`{`, `do` or `->` sits on, which Ruby shows through Proc#source_location. The
compiler took it from the enclosing scope's current line (c.push seeds
firstLine from the parent's curLine), and that is the same answer only when the
block opens on the enclosing statement's first line. So every block inside a
multi-line literal reported the literal's OPENING line:

    h = {                      # line 1
      :a => proc {             # line 2
        1
      },
      :b => proc { 2 },        # line 5
      :c => proc {             # line 6
      },
    }
    h[:a].source_location[1]   ruby 4.0.5  2    before  1
    h[:b].source_location[1]   ruby 4.0.5  5    before  1
    h[:c].source_location[1]   ruby 4.0.5  6    before  1

and the same for a multi-line array, a `do…end`, an arrow lambda and a default
in a multi-line parameter list -- 1, 8, 13 where ruby says 4, 10, 15.

The body's first line would NOT have done, which is why this needed the parser
and not a cheaper guess here: ruby answers 2 for :a, whose first statement is
on line 3, and :c has no body statement at all. The line was not in the tree,
so this could not be fixed downstream alone; parser v0.11.0 records it as
ast.Block.Line (go-ruby-parser/parser#46).

Block.Line == 0 means the parser recorded none -- a hand-built AST -- and the
inherited line stands, which is what it always was.

The control is that INSTRUCTION lines do not move. Backtraces, __LINE__ and
Kernel#caller inside a block were already right, and the fix also seeds the
block builder's curLine, so it could have shifted them. Measured unchanged, and
pinned: for a block opening on line 2 whose one statement is on line 3, the
scope's FirstLine is 2 and the instruction's line is 3.

The frozen prelude was regenerated, as its drift test requires. The same
`go generate ./internal/vm` also rewrites aot_e2e_gen_test.go, which has no
drift test and was already stale on main by the vcall change of #742 -- see
#762; its regeneration rides along here because the two generators share one
directive.

Closes #758

Co-authored-by: Claude Opus 5 <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.

1 participant