Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 71 additions & 40 deletions test/ast_transform/line_aligned_emitter_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@ def run_as_method(emitted)

emitted = emit(s(:begin, *reordered))

assert_includes emitted, '__ast_thunk_1__ = proc', emitted
assert_includes emitted, 'when_body', emitted
assert_includes emitted, '__ast_thunk_1__.call', emitted
# Execution order: proc defined, interaction runs, then the call.
assert_operator emitted.index('interaction_setup'), :<, emitted.index('__ast_thunk_1__.call'), emitted
# when_body stays on its source line (2) inside the proc; execution order is proc definition, interaction,
# then the call.
assert_equal <<~RUBY, emitted
given_setup; __ast_thunk_1__ = proc do
when_body; end
interaction_setup; __ast_thunk_1__.call
RUBY
end

test "thunk body statements stay on their source lines inside the proc" do
Expand All @@ -55,17 +57,23 @@ def run_as_method(emitted)

emitted = emit(s(:begin, *reordered))

assert_equal 2, emitted.lines.index { |line| line.include?('when_body_first') } + 1, emitted
assert_equal 3, emitted.lines.index { |line| line.include?('when_body_second') } + 1, emitted
assert_equal <<~RUBY, emitted
given_setup; __ast_thunk_1__ = proc do
when_body_first
when_body_second; end
interaction_setup; __ast_thunk_1__.call
RUBY
end

test "reusing one thunk node executes its body from multiple call sites (multiplexing)" do
shared = thunk(parse("shared_body\n"))

emitted = emit(s(:begin, shared, shared))

assert_equal 1, emitted.scan('__ast_thunk_1__ = proc').size, emitted
assert_equal 2, emitted.scan('__ast_thunk_1__.call').size, emitted
# One proc, one call per occurrence — all packed onto the body's single source line.
assert_equal <<~RUBY, emitted
__ast_thunk_1__ = proc do; shared_body; end; __ast_thunk_1__.call; __ast_thunk_1__.call
RUBY
end

test "a thunk whose body lines fall after its execution point raises ThunkLowering::PlacementError" do
Expand Down Expand Up @@ -93,8 +101,9 @@ def run_as_method(emitted)

emitted = emit(s(:begin, parse("real_statement\n"), synthetic))

assert_includes emitted, 'real_statement; __ast_thunk_1__ = proc', emitted
assert_operator emitted.index('synthetic_body'), :<, emitted.index('__ast_thunk_1__.call'), emitted
assert_equal <<~RUBY, emitted
real_statement; __ast_thunk_1__ = proc do; synthetic_body; end; __ast_thunk_1__.call
RUBY
end

test "distinct thunks get distinct hidden lvar names" do
Expand All @@ -103,8 +112,11 @@ def run_as_method(emitted)

emitted = emit(s(:begin, first_thunk, second_thunk))

assert_includes emitted, '__ast_thunk_1__ = proc', emitted
assert_includes emitted, '__ast_thunk_2__ = proc', emitted
assert_equal(
'__ast_thunk_1__ = proc do; first_body; end; __ast_thunk_1__.call; ' \
"__ast_thunk_2__ = proc do; second_body; end; __ast_thunk_2__.call\n",
emitted
)
end

test "an unlowered custom node type raises LineAlignedEmitter::UnloweredNodeTypeError" do
Expand All @@ -128,10 +140,23 @@ def risky
end
HEREDOC

emitted_lines = emit(parse(source)).lines.map(&:strip)
# Byte-identical to the source: every keyword and statement at its line and column.
assert_equal source, emit(parse(source))
end

# A fully synthetic ensure (e.g. a transform wrapping a body in teardown, like RSpock's Cleanup) has no keyword
# line to target: the keyword must open a fresh line, never `;`-pack after the preceding statement.
test "a loc-less ensure keyword goes on a fresh line, never packed" do
synthetic_def = s(:def, :run, s(:args),
s(:ensure, s(:send, nil, :work), s(:send, nil, :cleanup)))

emitted = emit(synthetic_def)

assert_equal ['def risky', 'compute', 'rescue ArgumentError, TypeError => error', 'handle(error)',
'else', 'celebrate', 'ensure', 'cleanup', 'end'], emitted_lines
# Everything is loc-less, so it packs — except the keyword, which opens a fresh line.
assert_equal <<~RUBY, emitted
def run; work
ensure; cleanup; end
RUBY
end

test "a standalone begin/end block emits its statements at their source lines" do
Expand All @@ -142,9 +167,7 @@ def risky
end
HEREDOC

emitted_lines = emit(parse(source)).lines.map(&:strip)

assert_equal ['begin', 'first_call', 'second_call', 'end'], emitted_lines
assert_equal source, emit(parse(source))
end

test "pre-declares locals the thunk body assigns at method scope" do
Expand All @@ -153,7 +176,13 @@ def risky

emitted = emit(s(:begin, *reordered))

assert_includes emitted, 'result = result; __ast_thunk_1__ = proc', emitted
# `result = result` registers the name at method scope before the proc; the thunked assignment stays on its
# source line inside it.
assert_equal <<~RUBY, emitted
given_setup; result = result; __ast_thunk_1__ = proc do
result = compute; end
interaction_setup; __ast_thunk_1__.call
RUBY
end

test "pre-declarations skip block-local assignments but cover the block call's arguments" do
Expand All @@ -164,18 +193,22 @@ def risky

emitted = emit(s(:begin, thunk(*parse(source).children)))

assert_includes emitted, 'outer = outer', emitted
# width is assigned in the block call's ARGUMENTS, which evaluate at
# method scope; inner is first assigned inside the block, so it is
# block-local in the original source too.
assert_includes emitted, 'width = width', emitted
refute_includes emitted, 'inner = inner', emitted
# outer and width get pre-declarations: width is assigned in the block call's ARGUMENTS, which evaluate at
# method scope. inner does not: it is first assigned inside the block, so it was block-local in the original
# source too.
assert_equal <<~RUBY, emitted
outer = outer; width = width; __ast_thunk_1__ = proc do; outer = items.map { |item,|; inner = item; }
buffer.take(width = limit) { |line,|; sink(line); }; end; __ast_thunk_1__.call
RUBY
end

test "pre-declarations skip assignments inside nested defs" do
emitted = emit(s(:begin, thunk(parse("def helper = (scoped = 1)\n"))))

refute_includes emitted, 'scoped = scoped', emitted
# No `scoped = scoped`: the def opens its own scope, so the assignment was never method-scope.
assert_equal <<~RUBY, emitted
__ast_thunk_1__ = proc do; def helper; scoped = 1; end; end; __ast_thunk_1__.call
RUBY
end

test "a thunked assignment runs late but propagates to the enclosing method scope" do
Expand Down Expand Up @@ -240,10 +273,8 @@ def risky
end
HEREDOC

emitted_lines = emit(parse(source)).lines.map(&:strip)

assert_equal 2, emitted_lines.index { |line| line.include?('first_call') } + 1, emitted_lines.join
assert_equal 4, emitted_lines.index { |line| line.include?('second_call') } + 1, emitted_lines.join
# Byte-identical to the source, blank line included.
assert_equal source, emit(parse(source))
end

test "a thunk composes inside expressions; its placement hoists to the enclosing sequence" do
Expand All @@ -255,11 +286,10 @@ def risky

emitted = emit(s(:begin, assert_raises_call))

assert_includes emitted, '__ast_thunk_1__ = proc', emitted
assert_includes emitted, 'assert_raises(RuntimeError)', emitted
assert_includes emitted, '__ast_thunk_1__.call', emitted
# The proc's definition precedes the assert_raises call that runs it.
assert_operator emitted.index('__ast_thunk_1__ = proc'), :<, emitted.index('assert_raises'), emitted
assert_equal <<~RUBY, emitted
__ast_thunk_1__ = proc do; raise_helper; end; assert_raises(RuntimeError) do; __ast_thunk_1__.call; end
RUBY
end

test "a thunk inside a def stays inside the def (scope boundary)" do
Expand All @@ -269,11 +299,12 @@ def risky

emitted = emit(thunked_def)

# The proc and its call both sit between the def opener and its end;
# the body statement keeps its source line.
assert_operator emitted.index('def run'), :<, emitted.index('__ast_thunk_1__ = proc'), emitted
assert_operator emitted.index('__ast_thunk_1__.call'), :<, emitted.rindex('end'), emitted
assert_equal 2, emitted.lines.index { |line| line.include?('helper') } + 1, emitted
# The proc and its call both sit between the def opener and its end; the body statement keeps its source line.
assert_equal <<~RUBY, emitted
def run; __ast_thunk_1__ = proc do
helper; end; __ast_thunk_1__.call
end
RUBY
end
end
end
52 changes: 33 additions & 19 deletions test/ast_transform/line_alignment_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,20 +61,44 @@ def self.compute
test "emission places each user statement at its source line" do
emitted = transform_file_source(FIXTURE_SOURCE, ASTTransform::Transformation.new)

FIXTURE_LINES.each do |statement, source_line|
assert_equal source_line, emitted_line_number(emitted, statement),
"expected `#{statement}` at source line #{source_line} in:\n#{numbered(emitted)}"
end
# Statements hold lines 3, 8 and 11; the vanished comments leave blank padding, and the multi-line expression
# is compressed back to its first line.
assert_equal <<~RUBY, emitted
class LineAlignmentFixture
def self.compute
first_value = 1




second_value = first_value + 1


raise_helper(second_value)
end
end
RUBY
end

test "emission keeps user statements on their source lines when a transform injects synthetic code" do
emitted = transform_file_source(FIXTURE_SOURCE, SetupInjectionTransformation.new)

assert_includes emitted, 'injected_setup'
FIXTURE_LINES.each do |statement, source_line|
assert_equal source_line, emitted_line_number(emitted, statement),
"expected `#{statement}` at source line #{source_line} in:\n#{numbered(emitted)}"
end
# The loc-less injected statement packs onto the def's line; every user statement keeps its source line.
assert_equal <<~RUBY, emitted
class LineAlignmentFixture
def self.compute; injected_setup
first_value = 1




second_value = first_value + 1


raise_helper(second_value)
end
end
RUBY
end

test "raw backtrace cites the source line of the raising statement, with no filtering" do
Expand Down Expand Up @@ -133,16 +157,6 @@ def tmp_pathname(file_name)
Pathname.new('').join(File.expand_path(''), 'tmp', 'test', 'ast_transform', file_name)
end

# 1-based line number of the first emitted line containing +statement+.
def emitted_line_number(emitted, statement)
index = emitted.lines.index { |line| line.include?(statement) }
index&.+(1)
end

def numbered(emitted)
emitted.lines.map.with_index(1) { |line, number| format('%3d| %s', number, line) }.join
end

# All line numbers the VM records for +iseq+ and its children — the lines
# a debugger can bind a `break file:N` to.
def iseq_lines(iseq)
Expand Down
5 changes: 3 additions & 2 deletions test/ast_transform/statement_renderer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,16 @@ def parse(source)

rendered = StatementRenderer.for_tree(statement).aligned_render(statement)

assert_equal 1, rendered.lines.size, rendered
# Unparser normalizes the modifier-if to if/end; the join restores the source's single-line height.
assert_equal 'if strict; raise(ArgumentError); end', rendered
end

test "aligned_render keeps a render that already fits its source height" do
statement = parse("def risky\n compute\nend\n")

rendered = StatementRenderer.for_tree(statement).aligned_render(statement)

assert_operator rendered.lines.size, :>, 1, rendered
assert_equal "def risky\n compute\nend", rendered
end

test "compress_to_single_line declines renders whose single-line join does not parse" do
Expand Down
Loading