Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add MutationVisitor#remove API to remove nodes from the tree #305

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -611,14 +611,17 @@ visitor.mutate("IfNode[predicate: Assign | OpAssign]") do |node|
node.copy(predicate: predicate)
end

source = "if a = 1; end"
# remove `do_more_work` method call node
visitor.remove("SyntaxTree::VCall[value: SyntaxTree::Ident[value: 'do_more_work']]")

source = "if a = 1; perform_work; do_more_work; end"
program = SyntaxTree.parse(source)

SyntaxTree::Formatter.format(source, program)
# => "if a = 1\nend\n"
# => "if a = 1\n perform_work\n do_more_work\nend\n"

SyntaxTree::Formatter.format(source, program.accept(visitor))
# => "if (a = 1)\nend\n"
# => "if (a = 1)\n perform_work\nend\n"
```

### WithScope
Expand Down
13 changes: 12 additions & 1 deletion lib/syntax_tree/mutation_visitor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ module SyntaxTree
# This visitor walks through the tree and copies each node as it is being
# visited. This is useful for mutating the tree before it is formatted.
class MutationVisitor < BasicVisitor
attr_reader :mutations
attr_reader :mutations, :removals

def initialize
@mutations = []
@removals = []
end

# Create a new mutation based on the given query that will mutate the node
Expand All @@ -19,13 +20,23 @@ def mutate(query, &block)
mutations << [Pattern.new(query).compile, block]
end

def remove(query)
@removals << Pattern.new(query).compile
end

# This is the base visit method for each node in the tree. It first creates
# a copy of the node using the visit_* methods defined below. Then it checks
# each mutation in sequence and calls it if it finds a match.
def visit(node)
return unless node
result = node.accept(self)

removals.each do |removal_pattern|
if removal_pattern.call(result)
return RemovedNode.new(location: result.location)
end
end

mutations.each do |(pattern, mutation)|
result = mutation.call(result) if pattern.call(result)
end
Expand Down
42 changes: 42 additions & 0 deletions lib/syntax_tree/node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9324,6 +9324,48 @@ def ambiguous?(q)
end
end

# RemovedNode is a blank node used in places of nodes that have been removed.
class RemovedNode < Node
# [Array[ Comment | EmbDoc ]] the comments attached to this node
attr_reader :comments

def initialize(location:)
Copy link
Author

Choose a reason for hiding this comment

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

I wasn't sure about exact API of the RemovedNode. I think we may want RemovedNode to point to an instance of the node it substitutes but I at the moment I don't have use-cases for it so I decided not to overcomplicate. Perhaps I should have simplified it even further and dropped the comments and location properties but some existing code expected every node to respond to comments so I decided to avoid breaking this expectation

@location = location
@comments = []
end

def accept(visitor)
visitor.visit_removed_node(self)
end

def child_nodes
[]
end

def copy(location: self.location)
node = RemovedNode.new(
location: location
)

node.comments.concat(comments.map(&:copy))

node
end

alias deconstruct child_nodes

def deconstruct_keys(_keys)
{ location: location, comments: comments }
end

def format(_q)
end

def ===(other)
other.is_a?(RemovedNode)
end
end

# RescueEx represents the list of exceptions being rescued in a rescue clause.
#
# begin
Expand Down
3 changes: 3 additions & 0 deletions lib/syntax_tree/visitor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,9 @@ class Visitor < BasicVisitor
# Visit a RegexpLiteral node.
alias visit_regexp_literal visit_child_nodes

# Visit a RemovedNode node.
alias visit_removed_node visit_child_nodes

# Visit a Rescue node.
alias visit_rescue visit_child_nodes

Expand Down
29 changes: 29 additions & 0 deletions test/mutation_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,35 @@ def test_mutates_based_on_patterns
assert_equal(expected, SyntaxTree::Formatter.format(source, program))
end

def test_removes_node
source = <<~RUBY
App.configure do |config|
config.config_value_a = 1
config.config_value_b = 2
config.config_value_c = 2
end
RUBY

expected = <<~RUBY
App.configure do |config|
config.config_value_a = 1
config.config_value_c = 2
end
RUBY

mutation_visitor = SyntaxTree.mutation do |mutation|
mutation.remove("SyntaxTree::Assign[
target: SyntaxTree::Field[
name: SyntaxTree::Ident[value: 'config_value_b']
],
]")
end

program = SyntaxTree.parse(source).accept(mutation_visitor)
assert_equal(expected, SyntaxTree::Formatter.format(source, program))
end

private

def build_mutation
Expand Down