Astel
Fast, Prism-native building blocks for Ruby source analysis and transformation
Features · Installation · Quick Start · Core APIs · Extensions · Development
Astel parses Ruby source once with Prism, then provides focused APIs for AST traversal, declarative node matching, and non-destructive source rewriting. It is designed as a library for codemods, linters, and other Ruby tooling rather than as a rule framework or CLI.
Features
- Parse source once and retain its AST, comments, errors, and formatting
- Visit the AST once while dispatching multiple node callbacks
- Compile reusable node patterns with captures and alternatives
- Record byte-safe edits with deterministic conflict detection
- Group related edits in atomic transactions and validate rewritten syntax
- Opt into semantic refactoring helpers and unified diffs
- Preserve source encodings, indentation, and CRLF line endings
Installation
Add Astel to your bundle:
bundle add astel
Or install it directly:
gem install astel
Requirements
- Ruby 3.3+
- Prism 0.30 or newer, but earlier than 2.0
Quick Start
Parse, find, and rewrite a method call:
require "astel"
source = Astel::SourceFile.from_string("old_name\n", path: "example.rb")
rewriter = Astel::Rewriter.new(source)
pattern = Astel::NodePattern.compile("(call_node name: :old_name)")
dispatcher = Astel::Dispatcher.new
dispatcher.on(:call_node) do |node|
rewriter.replace(node.location, "new_name") if pattern.match?(node)
end
dispatcher.run(source.ast)
rewriter.rewrite # => "new_name\n"
source.source # => "old_name\n"
Core APIs
| API | Purpose |
|---|---|
Astel::SourceFile |
Parse source and expose its AST, errors, comments, and formatting |
Astel::Dispatcher |
Visit the tree once and invoke callbacks by Prism node type |
Astel::NodePattern |
Compile and reuse declarative AST matchers |
Astel::Rewriter |
Record, validate, and apply non-destructive source edits |
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 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 invokes every callback registered for a visited Prism node
type:
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 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 supports replace, remove, insert_before, insert_after,
and wrap. It records edits without modifying the original SourceFile:
rewriter.replace(node.location, "new_name")
rewriter.rewrite(validate: :parse)
Overlapping edits raise Astel::Rewriter::ConflictError. Same-offset
insertions are emitted in registration order; pass
duplicate_insertions: :raise for strict duplicate handling.
Group related edits atomically:
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.
Inspect source formatting
SourceFile exposes byte-based positions and detected formatting:
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
Extensions
Extensions are opt-in and add no dependencies beyond Astel's core requirements.
Structured edits
require "astel/rewriter/structured"
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
require "astel/unified_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 dispatch, patterns, transactions, validation, and diffs.
How It Works
SourceFileparses source once with Prism and retains the parse result.DispatcherandNodePatternlocate relevant nodes without reparsing.Rewriterrecords flat edits and rejects overlaps when they are registered.rewriteapplies the edits in one pass and can validate the result with Prism.- The optional diff extension renders the final change as a unified patch.
Performance and Concurrency
Compile node patterns once and reuse them for every candidate node. Astel 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 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
Install dependencies and run the test suite:
bundle install
bundle exec rake
Verify the packaged gem:
bundle exec ruby script/package_smoke.rb
Run the deterministic source-rewrite fuzz check against Ruby source trees:
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
Released under the MIT License.