Astel

Gem Version 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, 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.