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
22 changes: 22 additions & 0 deletions lib/ast_transform/kwargs_builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

require 'prism/translation/parser'

module ASTTransform
# Extends the default Prism parser builder to distinguish keyword arguments
# from hash literals in the AST.
#
# The upstream builder always emits :hash nodes for both `foo(bar: 1)` and
# `foo({ bar: 1 })`. Unparser uses the node type to decide whether to emit
# braces: :hash gets `{}`, :kwargs does not. Since Ruby 3.0+ treats these as
# semantically different (strict keyword/positional separation), we need the
# AST to preserve the distinction.
class KwargsBuilder < Prism::Translation::Parser::Builder
def associate(begin_t, pairs, end_t)
node = super
return node unless begin_t.nil? && end_t.nil?

node.updated(:kwargs)
end
end
end
7 changes: 3 additions & 4 deletions lib/ast_transform/transformer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,16 @@
require 'prism'
require 'prism/translation/parser'
require 'unparser'
require 'ast_transform/kwargs_builder'
require 'ast_transform/source_map'

module ASTTransform
class Transformer
# Constructs a new Transformer instance.
#
# @param transformations [Array<ASTTransform::AbstractTransformation>] The transformations to be run.
# @param builder [Prism::Translation::Parser::Builder] The AST Node builder.
def initialize(*transformations, builder: Prism::Translation::Parser::Builder.new)
def initialize(*transformations)
@transformations = transformations
@builder = builder
end

# Builds the AST for the given +source+.
Expand Down Expand Up @@ -100,7 +99,7 @@ def create_buffer(source, file_path)

def parser
@parser&.reset
@parser ||= Prism::Translation::Parser.new(@builder)
@parser ||= Prism::Translation::Parser.new(ASTTransform::KwargsBuilder.new)
end

def register_source_map(source_file_path, transformed_file_path, transformed_ast, transformed_source)
Expand Down
98 changes: 98 additions & 0 deletions test/ast_transform/kwargs_builder_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# frozen_string_literal: true

require 'test_helper'
require 'ast_transform/kwargs_builder'

module ASTTransform
class KwargsBuilderTest < Minitest::Test
extend ASTTransform::Declarative

def setup
@builder = ASTTransform::KwargsBuilder.new
@parser = Prism::Translation::Parser.new(@builder)
end

test "#associate emits :kwargs for bare keyword arguments" do
ast = parse('foo(bar: 1, baz: 2)')
node = find_node(ast, :kwargs)

refute_nil node, "expected a :kwargs node for bare keyword arguments"
assert_equal :kwargs, node.type
end

test "#associate emits :hash for explicit hash with braces" do
ast = parse('foo({ bar: 1, baz: 2 })')
hash_node = find_node(ast, :hash)

refute_nil hash_node, "expected a :hash node for explicit hash"
assert_equal :hash, hash_node.type
assert_nil find_node(ast, :kwargs)
end

test "#associate emits :kwargs for keyword arguments mixed with positional arguments" do
ast = parse('foo("hello", bar: 1)')
node = find_node(ast, :kwargs)

refute_nil node, "expected a :kwargs node for keyword arguments"
assert_equal :kwargs, node.type
end

test "#associate emits :hash for standalone hash literals" do
ast = parse('x = { a: 1, b: 2 }')
hash_node = find_node(ast, :hash)

refute_nil hash_node, "expected a :hash node for hash literal"
assert_equal :hash, hash_node.type
assert_nil find_node(ast, :kwargs)
end

test "#associate preserves keyword argument pairs" do
ast = parse('foo(bar: 1, baz: 2)')
node = find_node(ast, :kwargs)

assert_equal 2, node.children.length
assert_equal :pair, node.children[0].type
assert_equal :pair, node.children[1].type

assert_equal :bar, node.children[0].children[0].children[0]
assert_equal :baz, node.children[1].children[0].children[0]
end

test "#associate emits :kwargs for keyword arguments in constructor calls" do
ast = parse('Foo.new(bar: 1, baz: 2)')
node = find_node(ast, :kwargs)

refute_nil node, "expected a :kwargs node in constructor call"
assert_equal :kwargs, node.type
end

test "#associate emits :kwargs for double-splat keyword arguments" do
ast = parse('foo(**opts)')
node = find_node(ast, :kwargs)

refute_nil node, "expected a :kwargs node for double-splat"
end

private

def parse(source)
buffer = Parser::Source::Buffer.new('test')
buffer.source = source
@parser.parse(buffer)
end

def find_node(ast, type)
return ast if ast.type == type
return nil unless ast.respond_to?(:children)

ast.children.each do |child|
next unless child.is_a?(Parser::AST::Node)

found = find_node(child, type)
return found if found
end

nil
end
end
end
42 changes: 42 additions & 0 deletions test/ast_transform/transformation_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,48 @@ class Bar
assert_equal expected, transform(source, @transformation)
end

test "transform! preserves keyword arguments in method calls" do
source = <<~HEREDOC
transform!(ASTTransform::TransformationTest::ClassNamePrefixerTransformation)
class Foo
def setup
@obj = MyClass.new(bar: 1, baz: 2)
end
end
HEREDOC

expected = <<~HEREDOC
class PrefixFoo
def setup
@obj = MyClass.new(bar: 1, baz: 2)
end
end
HEREDOC

assert_equal expected, transform(source, @transformation)
end

test "transform! preserves keyword arguments mixed with positional arguments" do
source = <<~HEREDOC
transform!(ASTTransform::TransformationTest::ClassNamePrefixerTransformation)
class Foo
def call
method("hello", bar: 1)
end
end
HEREDOC

expected = <<~HEREDOC
class PrefixFoo
def call
method("hello", bar: 1)
end
end
HEREDOC

assert_equal expected, transform(source, @transformation)
end

test "transform! runs the transformation on a constant assignment node" do
source = <<~HEREDOC
transform!(ASTTransform::TransformationTest::FooTransformation)
Expand Down