Ruby Bindings
Parsanol offers 3 parsing modes for Ruby. All modes return Slice objects with position information by default.
β¨ Unified parse() API
One method replaces five confusing options. The unified parse() API is simpler and more intuitive:
# Before 0.4.0 - Too many options! parse_parslet(g, i) parse_parslet_with_positions(g, i, cache) parse_with_transform(g, i, cache) parse_to_objects(g, i, map) parse_raw(atom, i) # After 0.4.0 - One method to rule them all result = Parsanol::Native.parse(grammar, input)
β¨ Position Info is Now Default
All parse methods now return Parsanol::Slice objects
with offset,
length,
line, and
column.
No special options needed!
π Ruby 4.0 Support (Coming Soon)
Parsanol 0.4.1 will support Ruby 4.0 via magnus 0.9.0
and rb-sys HEAD. These packages are not yet released.
The workspace Cargo.toml patches rb-sys automatically for ABI compatibility.
The 3 Parsing Modes
All modes return Slice objects with position info. Choose based on your output format needs.
| Mode | Description | Speed | Best For |
|---|---|---|---|
| Ruby | Pure Ruby parsing | 1x | Debugging, prototyping |
| Native | Rust parsing, Slice objects | ~200-1300x | Production use (RECOMMENDED) |
| JSON | Rust parsing, JSON output | ~200-1300x | APIs, serialization |
Benchmark Results
| Pattern Type | Ruby (i/s) | Native (i/s) | Speedup |
|---|---|---|---|
| Simple string | ~575 | ~775,000 | 1,340x |
| Sequence (3 parts) | ~580 | ~530,000 | 910x |
| Named capture | ~575 | ~510,000 | 880x |
| Repetition (unnamed) | ~560 | ~720,000 | 1,280x |
| Alternative | ~575 | ~720,000 | 1,250x |
| Calculator expression | ~570 | ~180,000 | 315x |
| Repetition (named) | ~575 | ~125,000 | 220x |
Speedup varies by pattern complexity. Simple patterns show highest speedups (1000-1300x), while patterns with named repetitions show lower speedups (200-400x) due to transformation overhead.
Batch Parsing Mode
Batch mode supports tagged AST nodes to preserve repetition and sequence semantics across the FFI boundary.
Tags like :repetition and :sequence
mark nodes for proper Ruby transformation.
π― When to Use Batch Mode
Batch mode is ideal when you need correct Parslet-compatible semantics for complex grammars with repetition patterns. It preserves the distinction between true repetitions and wrapper patterns.
# Batch mode with tagged AST nodes result = Parsanol::Native.parse_batch(grammar, input) # Results preserve :repetition and :sequence tags # This enables correct Ruby transformation for complex patterns
Pattern Detection
| Pattern | Example | Result |
|---|---|---|
| True Repetition | [{x:1}, {x:2}] |
Array of hashes |
| Wrapper Pattern | {x: {y:1}, z:2} |
Single hash |
| Duplicate Labels | [{a:{x:1}}, {a:{y:2}}] |
Last value wins |
Slice Objects
All parse results contain Parsanol::Slice objects
that preserve source position information:
# Parse result
result = parser.parse("SCHEMA test;", mode: :native)
# => {:name => "test"@7} # @7 shows byte offset
# Access the slice
slice = result[:name]
slice.to_s # => "test" (string content)
slice.offset # => 7 (byte position)
slice.length # => 4
slice.line_and_column # => [1, 8] (line, column - 1-indexed)
# String comparison works
slice == "test" # => true
# Extract from original source
slice.extract_from(input) # => "test"
Slice API
β‘ Lazy Line/Column
line_and_column is computed lazily and cached.
This means zero overhead if you don't need position info,
but it's always available when you do.
class Parsanol::Slice
# Core attributes (always available, zero cost)
def content # String content
def offset # Byte offset in original input
def length # Length of the slice
# Lazy computation (computed on first access, then cached)
def line_and_column # [line, column] tuple (1-indexed)
# String compatibility
def to_s # Returns content
def to_str # Implicit string conversion
def ==(other) # Compares content with String or Slice
# JSON serialization
def to_json # Returns { "value" => ..., "offset" => ..., ... }
def as_json # Returns hash with position info
# Utility
def to_span # Returns SourceSpan object
def extract_from(input) # Extracts content from original input
end
Linters
Precise error locations
IDEs
Go-to-definition, highlighting
Expressir
EXPRESS schema parsing
Comment Attachment
Attach remarks by position
Mode: Ruby (Baseline)
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β INPUT β β PARSANOL β β OUTPUT β
β String ββββββΆβ (Ruby) ββββββΆβSlice Objectsβ
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β
SLOW parsing
Pure Ruby
SPEED: 1x (baseline) - 425s (SRL benchmark)
Pure Ruby parsing. Use for debugging grammar issues or prototyping.
Mode: Native (Recommended)
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β INPUT β β PARSANOL β β OUTPUT β
β String ββββββΆβ (Rust) ββββββΆβSlice Objectsβ
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β
FAST parsing
Position tracking included
SPEED: ~200-1300x faster depending on pattern complexity
FEATURES: Slice objects with offset, length, line, column
Rust parsing with Slice objects. Recommended for production use. Position info is included by default - no special options needed.
Mode: JSON
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β INPUT β β PARSANOL β β OUTPUT β
β String ββββββΆβ (Rust) ββββββΆβJSON + Pos β
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β
FAST parsing
Position inline in JSON
SPEED: ~200-1300x faster depending on pattern complexity
FORMAT: {"value": "hello", "offset": 0, "length": 5, ...}
Rust parsing with JSON output. Position info is included inline. Perfect for APIs and cross-language interoperability.
When to Use Which Mode
Use Ruby mode when:
- β’ Debugging grammar issues
- β’ Prototyping new parsers
- β’ No Rust extension available
Use Native mode when: (RECOMMENDED)
- β’ Production use
- β’ Need Ruby objects with position
- β’ Building linters, IDEs, Expressir
- β’ Want maximum performance
Use JSON mode when:
- β’ Building APIs
- β’ Serializing parse results
- β’ Cross-language interoperability
Raw Tree Interface (Low-Level API)
For maximum throughput, Parsanol::Native.parse_raw
skips the parslet-compatibility transform entirely and hands you the engine's raw
tagged tree β batch-encoded across the FFI boundary and decoded in pure Ruby.
This is a stable, documented interface (it is what high-throughput consumers use).
| Method | Input | Returns | Use Case |
|---|---|---|---|
| parse | grammar, input | parslet-compatible tree | Default β native + transformed |
| parse_raw | grammar, input | raw tagged tree | Skip the transform β ~12-14% faster |
| parse_batch | grammar JSON, input, slice class | raw tagged tree | Flat u64 FFI protocol, minimal overhead |
| parse_prefix | grammar, input | [value, end_pos] | Streaming / partial parses |
Example: Calculator via the Raw Tree
Batch Decoding Without the Transform
Verify Performance Yourself
All performance claims can be verified by running the benchmarks on your own machine:
# Clone and setup
git clone https://github.com/parsanol/parsanol-ruby
cd parsanol-ruby
bundle install
# Build native extension (requires Rust)
bundle exec rake compile
# Run quick benchmarks (skips large inputs)
bundle exec ruby benchmark/run_all.rb --quick
# Run all benchmarks
bundle exec ruby benchmark/run_all.rb
Tip: Benchmark results are saved to
benchmark/reports/
as JSON files for comparison over time.
Streaming Parser
Parse large files without loading them entirely into memory. The streaming parser processes input in configurable chunks with a sliding window for backtracking support.
Use streaming when: Files are larger than available memory, or input arrives incrementally (network streams, pipes).
Basic Usage
require 'parsanol/streaming'
# Configure chunk size and backtracking window
config = Parsanol::Streaming::ChunkConfig.new(
chunk_size: 64 * 1024, # 64 KB chunks
window_size: 3 # Keep 3 chunks for backtracking
)
# Parse from a file
parser = Parsanol::StreamingParser.new(grammar, config)
result = parser.parse_from_file('large_file.json')
# Parse from any IO object
result = parser.parse_from_io(socket, config)
With Captures
Captures work with streaming and persist across chunk boundaries:
# Define grammar with captures
class LogParser < Parsanol::Parser
rule(:ip) { match('\d').repeat(1,3) >> str('.') >> match('\d').repeat(1,3) >> str('.') >> match('\d').repeat(1,3) >> str('.') >> match('\d').repeat(1,3) }
rule(:log_line) { capture(:ip, ip) >> str(' ') >> capture(:status, match('\d').repeat(3)) }
root(:log_line)
end
# Parse large log file with streaming
config = Parsanol::Streaming::ChunkConfig.new(chunk_size: 1024 * 1024, window_size: 2)
parser = Parsanol::StreamingParser.new(LogParser.new.grammar, config)
result = parser.parse_from_file('access.log')
# Access captures from streaming parse
result.captures[:ip].each { |ip| puts ip }
StreamingBuilder Callbacks
For maximum performance, use Parsanol::BuilderCallbacks
to receive parse events directly without intermediate AST construction:
class MyBuilder
include Parsanol::BuilderCallbacks
def on_rule(name, start_pos, end_pos)
puts "Rule #{name} at #{start_pos}..#{end_pos}"
end
def on_capture(name, value)
puts "Captured #{name}: #{value}"
end
end
builder = MyBuilder.new
parser.parse_from_file('large.json', builder: builder)
Limitations: Backtracking is limited to the window size. Some grammars may require larger windows. The current implementation collects chunks into memory before parsing.
Migration from Parslet
Parsanol provides full Parslet API compatibility. Simply change your require statement:
API Compatibility Matrix
| Parslet API | Status | Notes |
|---|---|---|
| str('foo') | β | Literal string match |
| match('[0-9]') | β | Character class |
| any | β | Any single character |
| >> (sequence) | β | Sequential composition |
| | (choice) | β | Ordered choice |
| .repeat(n, m) | β | Repetition with bounds |
| .maybe | β | Optional |
| .as(:name) | β | Label capture |
| .absent? | β | Negative lookahead |
| Parslet::Slice | β | Parsanol::Slice compatible |
| Parslet::Transform | β | Tree transformation |
| .capture(:name) | β | Named capture extraction NEW |
| scope { ... } | β | Isolated capture context NEW |
| dynamic { |ctx| ... } | β | Runtime-determined parsing NEW |
New in 1.1.0: The capture,
scope, and
dynamic atoms are now
available in Ruby with full FFI support. See the
Captures Guide
for details.