Astel
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, insert_after, and
wrap. Overlapping edits raise Astel::Rewriter::ConflictError, and
Astel::Rewriter#edits returns an immutable snapshot of registered edits.
Same-offset insertions are concatenated in registration order. Pass
duplicate_insertions: :raise to retain the strict behavior from Astel 0.1.
Group related edits atomically with a transaction:
rewriter.transaction do |rw|
rw.replace(first.location, "first")
rw.replace(second.location, "second")
end
The transaction returns false without registering any edits when a conflict
occurs. Pass raise_on_conflict: true to raise instead. Use
rewrite(validate: :parse) to reject rewritten source containing syntax errors.
Source formatting
SourceFile exposes byte-based line and column lookup, line locations, source
slicing, dominant newline detection, and indentation detection:
source.line_at(node.location.start_offset)
source.column_at(node.location.start_offset)
source.indentation_at(node.location.start_offset)
source.newline
source.indent_unit
Refactoring helpers
Load semantic source-editing helpers explicitly:
require "astel/refactor"
rewriter = Astel::Rewriter.new(source)
rewriter.remove_keyword_argument(call_node, :required)
rewriter.insert_into_body(class_node, "def added\nend", position: :before_private)
See Refactoring recipes for the complete API and examples.
Unified diffs
Diff support is also opt-in and has no additional runtime dependency:
require "astel/diff"
puts rewriter.to_diff(context: 3)
As with Git-generated zero-context patches, context: 0 requires
git apply --unidiff-zero.
See the codemod guide for an end-to-end example using the dispatcher, node patterns, transactions, validation, and diffs.
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
Scope
Astel intentionally does not provide a nested action tree, file discovery, parallel execution, a CLI, a rule framework, or a parser compatibility layer. Those concerns stay in applications built on Astel. Source edits remain flat; overlapping ranges raise instead of being silently reordered or discarded.
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
Run the deterministic source-rewrite fuzz check against Ruby source trees with:
SEED=123 bundle exec ruby script/fuzz.rb path/to/gem/sources
CI runs the same check weekly against Rails, RuboCop, Sidekiq, Faraday, and Prism source releases.
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.