Astel

Ruby License

Astel provides fast, reusable building blocks for Ruby source analysis and transformation on top of Prism. It parses each source once and provides APIs for AST traversal, declarative node matching, and non-destructive source rewriting.

Installation

Install Astel with Bundler:

bundle add astel

Or install it directly with RubyGems:

gem install astel

Astel requires Ruby 3.3 or newer and Prism 0.30 or newer, but earlier than 2.0.

Usage

Require Astel before using its APIs:

require "astel"

Parse source

Parse a file from disk:

source = Astel::SourceFile.parse(path: "example.rb")

source.ast       # => Prism::ProgramNode
source.comments  # => Prism comments
source.errors    # => Prism parse errors
source.valid?    # => true when there are no parse errors

Use Astel::SourceFile.from_string when the source is already in memory:

source = Astel::SourceFile.from_string("value = 1\n", path: "example.rb")

Traverse the AST

Astel::Dispatcher walks the tree once and invokes callbacks registered for specific Prism node types:

source = Astel::SourceFile.from_string(<<~RUBY)
  puts "hello"
  "value".freeze
RUBY

dispatcher = Astel::Dispatcher.new
dispatcher.on(:call_node) { |node| puts node.name }
dispatcher.run(source.ast)

Multiple callbacks can be registered for the same node type.

Match nodes

Astel::NodePattern compiles a declarative pattern that can be reused across nodes:

source = Astel::SourceFile.from_string('"value".freeze')
node = source.ast.statements.body.first

pattern = Astel::NodePattern.compile(<<~PATTERN)
  (call_node receiver: (string_node) name: :freeze)
PATTERN

pattern.match?(node) # => true

Prefix a subpattern with $ to capture its matched value:

pattern = Astel::NodePattern.compile(
  "(call_node receiver: $(string_node) name: $:freeze)"
)

captures = pattern.match(node)
captures.first # => Prism::StringNode
captures.last  # => :freeze

Patterns support node types, named fields, _ for any non-nil value, nil, symbol, string, integer, and boolean literals, { ... } alternatives, and $ captures.

Rewrite source

Astel::Rewriter records edits without modifying the original SourceFile:

source = Astel::SourceFile.from_string("old_name\n")
node = source.ast.statements.body.first

rewriter = Astel::Rewriter.new(source)
rewriter.replace(node.location, "new_name")

rewriter.rewrite # => "new_name\n"
source.source    # => "old_name\n"

The rewriter supports replace, remove, insert_before, and insert_after. Overlapping edits raise Astel::Rewriter::ConflictError, and Astel::Rewriter#edits returns an immutable snapshot of registered edits.

Performance and concurrency

Compile node patterns once and reuse them for every candidate node. Astel also keeps a bounded cache of compiler output for repeated pattern strings.

For repository-wide tools, process independent files in worker processes at the application layer. Keep each SourceFile and its Prism AST inside the worker that parsed it; Astel intentionally does not own a process pool or move ASTs between workers.

Benchmarks for dispatching, node patterns, and rewriting are available under benchmark/:

bundle exec ruby benchmark/dispatch_bench.rb
bundle exec ruby benchmark/node_pattern_bench.rb
bundle exec ruby benchmark/rewriter_bench.rb

Development

After checking out the repository, install dependencies and run the test suite:

bundle install
bundle exec rake

Verify the packaged gem with:

bundle exec ruby script/package_smoke.rb

Contributing

Bug reports and pull requests are welcome on GitHub. Please include tests for behavior changes and keep the existing test and performance gates passing.

See CHANGELOG.md for notable changes.

License

The gem is available as open source under the terms of the MIT License.