@parsanol/wasm 0.8.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2058 -0
- package/package.json +38 -0
- package/parsanol.d.ts +131 -0
- package/parsanol.js +446 -0
- package/parsanol_bg.wasm +0 -0
- package/parsanol_bg.wasm.d.ts +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,2058 @@
|
|
|
1
|
+
# Parsanol-rs
|
|
2
|
+
|
|
3
|
+
A high-performance PEG (Parsing Expression Grammar) parser library for Rust with packrat memoization and arena allocation.
|
|
4
|
+
|
|
5
|
+
[](https://crates.io/crates/parsanol)
|
|
6
|
+
[](https://docs.rs/parsanol)
|
|
7
|
+
[](https://github.com/parsanol/parsanol-rs/blob/main/LICENSE)
|
|
8
|
+
[](https://github.com/parsanol/parsanol-rs/actions/workflows/ci.yml)
|
|
9
|
+
|
|
10
|
+
## Purpose
|
|
11
|
+
|
|
12
|
+
Parsanol-rs is a generic, domain-agnostic PEG parser library written in
|
|
13
|
+
Rust. It provides high-performance parsing capabilities with a focus on:
|
|
14
|
+
|
|
15
|
+
- **Speed**: Packrat memoization for O(n) parsing complexity
|
|
16
|
+
|
|
17
|
+
- **Memory efficiency**: Arena allocation for zero-copy AST construction
|
|
18
|
+
|
|
19
|
+
- **Developer experience**: Fluent API for building grammars, rich error
|
|
20
|
+
reporting
|
|
21
|
+
|
|
22
|
+
- **Flexibility**: Transform system for converting parse trees to typed
|
|
23
|
+
Rust structs via derive macros
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
- [Quick Start](#quick-start) - Get started in minutes
|
|
28
|
+
- [Backend Abstraction](#backend-abstraction) - Extensible backend trait system
|
|
29
|
+
- [Bytecode Backend](#bytecode-backend) - Optional VM backend for linear patterns
|
|
30
|
+
- [Parser DSL](#parser-dsl) - Fluent API for grammar definition
|
|
31
|
+
- [Capture Atoms](#capture-atoms) - Extract named values during parsing
|
|
32
|
+
- [Scope Atoms](#scope-atoms) - Isolated capture contexts
|
|
33
|
+
- [Dynamic Atoms](#dynamic-atoms) - Runtime-determined parsing via callbacks
|
|
34
|
+
- [Streaming with Captures](#streaming-with-captures) - Memory-efficient parsing with capture support
|
|
35
|
+
- [Transform System](#transform-system) - Convert parse trees to typed structs
|
|
36
|
+
- [Derive Macros](#derive-macros) - Automatic typed AST generation
|
|
37
|
+
- [Streaming Builder](#streaming-builder) - Single-pass parsing with custom output
|
|
38
|
+
- [Parallel Parsing](#parallel-parsing) - Multi-file parsing with rayon
|
|
39
|
+
- [Infix Expression Parsing](#infix-expression-parsing) - Built-in operator precedence
|
|
40
|
+
- [Rich Error Reporting](#rich-error-reporting) - Tree-structured error messages
|
|
41
|
+
- [Source Location Tracking](#source-location-tracking) - Line/column tracking through transforms
|
|
42
|
+
- [Grammar Composition](#grammar-composition) - Import and compose grammars
|
|
43
|
+
- [Ruby FFI](#ruby-ffi) - Optional Ruby bindings
|
|
44
|
+
- [WASM Support](#wasm-support) - Optional WebAssembly bindings
|
|
45
|
+
|
|
46
|
+
# Bytecode Backend
|
|
47
|
+
|
|
48
|
+
Parsanol-rs supports two parsing backends:
|
|
49
|
+
|
|
50
|
+
1. **Packrat (default)**: Memoization-based parser with O(n) time complexity for all grammars
|
|
51
|
+
2. **Bytecode VM**: Stack-based virtual machine with optimization passes
|
|
52
|
+
|
|
53
|
+
## Backend Comparison
|
|
54
|
+
|
|
55
|
+
Both backends produce **identical parsing results** for all valid inputs. The difference lies in performance characteristics:
|
|
56
|
+
|
|
57
|
+
| Aspect | Packrat | Bytecode VM |
|
|
58
|
+
|--------|---------|-------------|
|
|
59
|
+
| **Time Complexity** | Guaranteed O(n) | O(n) to O(2^n) depending on grammar |
|
|
60
|
+
| **Memory Usage** | Higher (memoization table) | Lower (stack-based) |
|
|
61
|
+
| **Compilation** | None required | Pre-compilation needed |
|
|
62
|
+
| **Nested Repetitions** | Handles efficiently | Can be exponential |
|
|
63
|
+
| **Simple Patterns** | Good | Excellent |
|
|
64
|
+
| **Predictability** | Consistent performance | Varies by grammar |
|
|
65
|
+
|
|
66
|
+
### Performance Characteristics
|
|
67
|
+
|
|
68
|
+
**Packrat Backend:**
|
|
69
|
+
- Memoization stores parse results at each position
|
|
70
|
+
- Guarantees O(n) time complexity regardless of grammar structure
|
|
71
|
+
- Memory overhead scales with input size and grammar complexity
|
|
72
|
+
- Ideal when predictable performance is required
|
|
73
|
+
|
|
74
|
+
**Bytecode VM Backend:**
|
|
75
|
+
- Stack-based execution with backtracking
|
|
76
|
+
- O(n) for linear patterns (most common case)
|
|
77
|
+
- Can exhibit O(2^n) behavior for pathological patterns like `(a*)*`
|
|
78
|
+
- Lower memory footprint, good for memory-constrained environments
|
|
79
|
+
- Pre-compilation enables optimization passes
|
|
80
|
+
|
|
81
|
+
### Decision Matrix
|
|
82
|
+
|
|
83
|
+
| Grammar Type | Recommended Backend | Reason |
|
|
84
|
+
|--------------|---------------------|--------|
|
|
85
|
+
| JSON, XML, config files | Either | Linear patterns, both perform well |
|
|
86
|
+
| Programming languages | Packrat | Complex grammar with nested structures |
|
|
87
|
+
| Log parsing | Bytecode | Simple patterns, streaming potential |
|
|
88
|
+
| Nested repetitions `(a*)*` | Packrat | Avoids exponential backtracking |
|
|
89
|
+
| Memory-constrained | Bytecode | Lower memory footprint |
|
|
90
|
+
| Need predictable O(n) | Packrat | Guaranteed linear time |
|
|
91
|
+
|
|
92
|
+
### Automatic Selection
|
|
93
|
+
|
|
94
|
+
Use `Backend::Auto` (the default) to let parsanol analyze your grammar:
|
|
95
|
+
|
|
96
|
+
```rust
|
|
97
|
+
// Automatic selection (default)
|
|
98
|
+
let mut parser = Parser::auto(grammar);
|
|
99
|
+
|
|
100
|
+
// Or explicitly:
|
|
101
|
+
let mut parser = Parser::new(grammar, Backend::Auto);
|
|
102
|
+
|
|
103
|
+
// Check the analysis
|
|
104
|
+
let analysis = parser.analysis();
|
|
105
|
+
println!("Has nested repetitions: {}", analysis.has_nested_repetition);
|
|
106
|
+
println!("Recommended: {:?}", analysis.recommended_backend());
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Why Nested Repetitions Are the Criterion
|
|
110
|
+
|
|
111
|
+
The backend selection is based on a **single hard rule**:
|
|
112
|
+
|
|
113
|
+
- **Has nested repetitions** (e.g., `(a*)*`) → **Packrat**
|
|
114
|
+
- **Otherwise** → **Bytecode**
|
|
115
|
+
|
|
116
|
+
This is the only criterion because nested repetitions are the **only pattern that causes exponential time complexity** in the bytecode backend. Here's why:
|
|
117
|
+
|
|
118
|
+
**The Algorithmic Problem:**
|
|
119
|
+
|
|
120
|
+
When a repetition contains another repetition, the parser must try all possible ways to divide the input. For pattern `(a*)*` on input "aaa":
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
Division 1: (aaa) - outer * matches 1 group
|
|
124
|
+
Division 2: (aa)(a) - outer * matches 2 groups
|
|
125
|
+
Division 3: (a)(aa) - outer * matches 2 groups (different split)
|
|
126
|
+
Division 4: (a)(a)(a) - outer * matches 3 groups
|
|
127
|
+
... and so on
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The number of ways to partition n characters is O(2^n). The bytecode VM tries each possibility via backtracking, leading to exponential time.
|
|
131
|
+
|
|
132
|
+
**Why Packrat Solves It:**
|
|
133
|
+
|
|
134
|
+
Packrat memoizes results by (position, rule). Once `(a*)` is evaluated at position i, the result is cached. Subsequent evaluations at the same position are O(1) cache hits. This guarantees O(n) total time.
|
|
135
|
+
|
|
136
|
+
**Why Other Patterns Don't Matter:**
|
|
137
|
+
|
|
138
|
+
| Pattern | Time Impact | Backend Difference |
|
|
139
|
+
|---------|-------------|-------------------|
|
|
140
|
+
| Overlapping choices (`"a" \| "aa"`) | Linear backtracking | Both handle identically |
|
|
141
|
+
| Deep nesting | Stack depth increases | Both handle fine |
|
|
142
|
+
| Many alternatives | More choice points | Linear in alternative count |
|
|
143
|
+
| Left recursion | Infinite loop | **Both fail** - not a backend issue |
|
|
144
|
+
|
|
145
|
+
### How the Analysis Works
|
|
146
|
+
|
|
147
|
+
The grammar analysis is deliberately simple:
|
|
148
|
+
|
|
149
|
+
```rust
|
|
150
|
+
pub struct GrammarAnalysis {
|
|
151
|
+
/// Total atoms in the grammar
|
|
152
|
+
pub atom_count: usize,
|
|
153
|
+
/// Whether any Repetition contains another Repetition
|
|
154
|
+
pub has_nested_repetition: bool,
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The algorithm iterates through all atoms and checks: "Is this a Repetition whose inner atom is also a Repetition?"
|
|
159
|
+
|
|
160
|
+
```rust
|
|
161
|
+
for atom in &grammar.atoms {
|
|
162
|
+
if let Atom::Repetition { atom: inner_idx, .. } = atom {
|
|
163
|
+
if let Some(inner) = grammar.get_atom(*inner_idx) {
|
|
164
|
+
if matches!(inner, Atom::Repetition { .. }) {
|
|
165
|
+
has_nested_repetition = true;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
This is O(atoms) time and detects the only pattern that matters for backend selection.
|
|
174
|
+
|
|
175
|
+
### When to Override Auto-Selection
|
|
176
|
+
|
|
177
|
+
The auto-selection only considers **time complexity**. You may want to manually select based on:
|
|
178
|
+
|
|
179
|
+
| Scenario | Manual Selection | Rationale |
|
|
180
|
+
|----------|------------------|-----------|
|
|
181
|
+
| **Memory-constrained** (embedded, WASM) | `Backend::Bytecode` | Lower memory: O(depth) vs O(n×rules) |
|
|
182
|
+
| **Very large files** (>100MB) | `Backend::Bytecode` | Packrat table grows with input size |
|
|
183
|
+
| **Predictable latency required** | `Backend::Packrat` | Guaranteed O(n), no pathological cases |
|
|
184
|
+
| **Streaming parsing** | `Backend::Bytecode` | Packrat requires full input in memory |
|
|
185
|
+
| **Incremental re-parsing** | `Backend::Packrat` | Memo table can be reused for unchanged portions |
|
|
186
|
+
| **Grammar has nested repetitions but input is bounded** | Either | If input is always small, exponential doesn't matter |
|
|
187
|
+
| **Testing/debugging** | `Backend::Packrat` | Consistent behavior across all inputs |
|
|
188
|
+
|
|
189
|
+
```rust
|
|
190
|
+
// Memory-constrained environment
|
|
191
|
+
let mut parser = Parser::bytecode(grammar);
|
|
192
|
+
|
|
193
|
+
// Safety-critical with guaranteed O(n)
|
|
194
|
+
let mut parser = Parser::packrat(grammar);
|
|
195
|
+
|
|
196
|
+
// Explicit choice regardless of analysis
|
|
197
|
+
let mut parser = Parser::new(grammar, Backend::Packrat);
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Problematic Grammar Patterns
|
|
201
|
+
|
|
202
|
+
The following patterns can cause exponential O(2^n) behavior in the Bytecode backend.
|
|
203
|
+
They are **safe with Packrat** due to memoization. If your grammar contains these,
|
|
204
|
+
use Packrat explicitly or rely on `Backend::Auto`.
|
|
205
|
+
|
|
206
|
+
**Critical Pattern: Nested Repetitions**
|
|
207
|
+
```
|
|
208
|
+
(a*)* // CRITICAL: Outer * tries O(2^n) ways to divide input
|
|
209
|
+
(a+)+ // Same issue
|
|
210
|
+
((a|b)*)* // Even worse with choice
|
|
211
|
+
|
|
212
|
+
// Safe alternatives:
|
|
213
|
+
a* // Single repetition - O(n)
|
|
214
|
+
(a b)* // Fixed sequence inside - O(n)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**Moderate Pattern: Overlapping Choice Prefixes**
|
|
218
|
+
```
|
|
219
|
+
// Problematic: All start with 'a'
|
|
220
|
+
("a" | "aa" | "aaa")+
|
|
221
|
+
|
|
222
|
+
// Better: Distinct first characters
|
|
223
|
+
("a" | "b" | "c")+
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**Safe Pattern: Deep Recursion (Both handle well)**
|
|
227
|
+
```
|
|
228
|
+
expr = term (("+" | "-") term)*
|
|
229
|
+
// Recursive but structured - both backends handle efficiently
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Analyzing Your Grammar
|
|
233
|
+
|
|
234
|
+
Use the GrammarAnalysis API to check for nested repetitions:
|
|
235
|
+
|
|
236
|
+
```rust
|
|
237
|
+
use parsanol::portable::{
|
|
238
|
+
parser_dsl::{str, re, GrammarBuilder},
|
|
239
|
+
bytecode::{Backend, GrammarAnalysis, Parser},
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
fn main() {
|
|
243
|
+
let grammar = GrammarBuilder::new()
|
|
244
|
+
.rule("expr", re(r"[0-9]+"))
|
|
245
|
+
.build();
|
|
246
|
+
|
|
247
|
+
// Analyze the grammar
|
|
248
|
+
let analysis = GrammarAnalysis::analyze(&grammar);
|
|
249
|
+
|
|
250
|
+
// The only field that matters for backend selection
|
|
251
|
+
if analysis.has_nested_repetition {
|
|
252
|
+
println!("⚠️ Nested repetitions detected - use Packrat!");
|
|
253
|
+
} else {
|
|
254
|
+
println!("✅ No nested repetitions - Bytecode is efficient");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Get recommendation (hard rule: nested repetition → Packrat, else → Bytecode)
|
|
258
|
+
println!("Recommended: {:?}", analysis.recommended_backend());
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
**GrammarAnalysis Fields:**
|
|
263
|
+
|
|
264
|
+
| Field | Type | Purpose |
|
|
265
|
+
|-------|------|---------|
|
|
266
|
+
| `atom_count` | `usize` | Number of atoms in grammar (informational) |
|
|
267
|
+
| `has_nested_repetition` | `bool` | **The criterion** - if true, use Packrat |
|
|
268
|
+
|
|
269
|
+
**The `recommended_backend()` Method:**
|
|
270
|
+
|
|
271
|
+
Returns `Backend::Packrat` if `has_nested_repetition` is true, otherwise `Backend::Bytecode`. This is what `Backend::Auto` uses internally.
|
|
272
|
+
|
|
273
|
+
## Using the Bytecode Backend
|
|
274
|
+
|
|
275
|
+
```rust
|
|
276
|
+
use parsanol::portable::{
|
|
277
|
+
parser_dsl::{str, re, GrammarBuilder},
|
|
278
|
+
bytecode::{Backend, Parser},
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
let grammar = GrammarBuilder::new()
|
|
282
|
+
.rule("number", re(r"[0-9]+"))
|
|
283
|
+
.build();
|
|
284
|
+
|
|
285
|
+
// Create parser with bytecode backend
|
|
286
|
+
let mut parser = Parser::new(grammar, Backend::Bytecode);
|
|
287
|
+
let result = parser.parse("42");
|
|
288
|
+
|
|
289
|
+
// Or use auto-selection (analyzes grammar complexity)
|
|
290
|
+
let mut parser = Parser::auto(grammar);
|
|
291
|
+
let result = parser.parse("42");
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Known Differences
|
|
295
|
+
|
|
296
|
+
Both backends produce **identical results** for the vast majority of patterns. However, there are edge cases where behavior differs:
|
|
297
|
+
|
|
298
|
+
**Alternatives in Sequences**: For patterns like `("a" | "aa") "b"` on input `"aab"`:
|
|
299
|
+
- **Packrat**: May succeed due to memoization re-evaluation
|
|
300
|
+
- **Bytecode**: Fails (standard PEG semantics - once "a" succeeds, "aa" is not tried)
|
|
301
|
+
|
|
302
|
+
This difference only affects patterns with:
|
|
303
|
+
- Alternatives containing overlapping prefixes ("a" vs "aa")
|
|
304
|
+
- The alternative is followed by content that fails
|
|
305
|
+
- The later alternative would allow the following content to succeed
|
|
306
|
+
|
|
307
|
+
For most practical grammars, this difference never manifests. Use `Backend::Auto` to let parsanol choose the appropriate backend.
|
|
308
|
+
|
|
309
|
+
## Backend Abstraction
|
|
310
|
+
|
|
311
|
+
Parsanol provides a trait-based backend abstraction for extensibility. You can implement custom backends or use the built-in ones interchangeably.
|
|
312
|
+
|
|
313
|
+
### Using the ParsingBackend Trait
|
|
314
|
+
|
|
315
|
+
```rust
|
|
316
|
+
use parsanol::portable::backend::{ParsingBackend, PackratBackend, BytecodeBackend, Backend};
|
|
317
|
+
|
|
318
|
+
// Use Packrat backend for predictable O(n) performance
|
|
319
|
+
let mut packrat = PackratBackend::new();
|
|
320
|
+
let result = packrat.parse(&grammar, input)?;
|
|
321
|
+
|
|
322
|
+
// Use Bytecode backend for lower memory usage
|
|
323
|
+
let mut bytecode = BytecodeBackend::new();
|
|
324
|
+
let result = bytecode.parse(&grammar, input)?;
|
|
325
|
+
|
|
326
|
+
// Configure backends
|
|
327
|
+
let packrat = PackratBackend::new()
|
|
328
|
+
.with_max_recursion_depth(500)
|
|
329
|
+
.with_timeout_ms(5000);
|
|
330
|
+
|
|
331
|
+
let bytecode = BytecodeBackend::new()
|
|
332
|
+
.with_auto_fallback(true); // Falls back to Packrat for complex grammars
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Runtime Backend Selection
|
|
336
|
+
|
|
337
|
+
```rust
|
|
338
|
+
use parsanol::portable::backend::Backend;
|
|
339
|
+
|
|
340
|
+
// Select backend at runtime
|
|
341
|
+
let backend_type = Backend::default_for_grammar(&grammar);
|
|
342
|
+
|
|
343
|
+
match backend_type {
|
|
344
|
+
Backend::Packrat => {
|
|
345
|
+
let mut parser = PackratBackend::new();
|
|
346
|
+
parser.parse(&grammar, input)?
|
|
347
|
+
}
|
|
348
|
+
Backend::Bytecode => {
|
|
349
|
+
let mut parser = BytecodeBackend::new();
|
|
350
|
+
parser.parse(&grammar, input)?
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Backend Characteristics
|
|
356
|
+
|
|
357
|
+
Each backend documents its performance characteristics:
|
|
358
|
+
|
|
359
|
+
```rust
|
|
360
|
+
use parsanol::portable::backend::{ParsingBackend, PackratBackend};
|
|
361
|
+
|
|
362
|
+
let backend = PackratBackend::new();
|
|
363
|
+
let chars = backend.characteristics();
|
|
364
|
+
|
|
365
|
+
println!("Time: {}", chars.time_complexity); // "O(n)"
|
|
366
|
+
println!("Memory: {}", chars.memory_complexity); // "O(n × r)"
|
|
367
|
+
println!("Memoization: {}", chars.uses_memoization); // true
|
|
368
|
+
println!("Streaming: {}", chars.supports_streaming); // false
|
|
369
|
+
println!("Incremental: {}", chars.supports_incremental); // true
|
|
370
|
+
println!("Safe: {}", chars.safe_for_all_grammars); // true
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Implementing Custom Backends
|
|
374
|
+
|
|
375
|
+
```rust
|
|
376
|
+
use parsanol::portable::backend::{ParsingBackend, BackendCharacteristics, BackendResult};
|
|
377
|
+
use parsanol::portable::grammar::Grammar;
|
|
378
|
+
|
|
379
|
+
struct MyCustomBackend;
|
|
380
|
+
|
|
381
|
+
impl ParsingBackend for MyCustomBackend {
|
|
382
|
+
fn parse(&mut self, grammar: &Grammar, input: &str) -> BackendResult {
|
|
383
|
+
// Custom parsing logic here
|
|
384
|
+
todo!()
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
fn name(&self) -> &'static str {
|
|
388
|
+
"my-custom"
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
fn characteristics(&self) -> BackendCharacteristics {
|
|
392
|
+
BackendCharacteristics {
|
|
393
|
+
time_complexity: "O(n log n)",
|
|
394
|
+
memory_complexity: "O(n)",
|
|
395
|
+
uses_memoization: false,
|
|
396
|
+
supports_streaming: true,
|
|
397
|
+
supports_incremental: false,
|
|
398
|
+
safe_for_all_grammars: true,
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### Dynamic Backend Dispatch
|
|
405
|
+
|
|
406
|
+
For runtime polymorphism:
|
|
407
|
+
|
|
408
|
+
```rust
|
|
409
|
+
use parsanol::portable::backend::{DynBackend, PackratBackend, BytecodeBackend};
|
|
410
|
+
|
|
411
|
+
fn get_backend(use_packrat: bool) -> DynBackend {
|
|
412
|
+
if use_packrat {
|
|
413
|
+
Box::new(PackratBackend::new())
|
|
414
|
+
} else {
|
|
415
|
+
Box::new(BytecodeBackend::new())
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
let mut backend: DynBackend = get_backend(true);
|
|
420
|
+
let result = backend.parse(&grammar, input)?;
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
## Quick Start Examples
|
|
424
|
+
|
|
425
|
+
Using the bytecode backend explicitly:
|
|
426
|
+
|
|
427
|
+
```rust
|
|
428
|
+
use parsanol::portable::{
|
|
429
|
+
parser_dsl::{str, re, GrammarBuilder},
|
|
430
|
+
bytecode::{Backend, Parser},
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
let grammar = GrammarBuilder::new()
|
|
434
|
+
.rule("number", re(r"[0-9]+"))
|
|
435
|
+
.build();
|
|
436
|
+
|
|
437
|
+
// Create parser with bytecode backend
|
|
438
|
+
let mut parser = Parser::new(grammar, Backend::Bytecode);
|
|
439
|
+
let result = parser.parse("42");
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Using packrat backend explicitly:
|
|
443
|
+
|
|
444
|
+
```rust
|
|
445
|
+
let mut parser = Parser::new(grammar, Backend::Packrat);
|
|
446
|
+
let result = parser.parse("42");
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
## Optimization Passes
|
|
450
|
+
|
|
451
|
+
The bytecode backend applies 11 optimization passes automatically:
|
|
452
|
+
|
|
453
|
+
1. `DeadCodeElimination` - Remove unreachable code
|
|
454
|
+
2. `JumpChainSimplification` - Simplify jump chains
|
|
455
|
+
3. `JumpToReturnSimplification` - Direct returns
|
|
456
|
+
4. `JumpToFailSimplification` - Direct failures
|
|
457
|
+
5. `CombineAdjacentChars` - Char merging
|
|
458
|
+
6. `SpanOptimization` - CharSet* to Span
|
|
459
|
+
7. `FullCaptureOptimization` - Capture pairs to FullCapture
|
|
460
|
+
8. `TestCharOptimization` - Choice patterns to TestChar
|
|
461
|
+
9. `TestSetOptimization` - Choice patterns to TestSet
|
|
462
|
+
10. `TailCallOptimization` - Tail calls to jumps
|
|
463
|
+
11. `LookaheadOptimization` - Choice to PredChoice for predicates
|
|
464
|
+
|
|
465
|
+
## Bytecode VM Architecture
|
|
466
|
+
|
|
467
|
+
```
|
|
468
|
+
Grammar (Atoms) ──► Compiler ──► Program (bytecode)
|
|
469
|
+
│
|
|
470
|
+
▼
|
|
471
|
+
Input ──────────────────────────► VM ──► AstNode
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
The bytecode VM uses:
|
|
475
|
+
- **Backtracking stack**: For choice point management
|
|
476
|
+
- **Capture stack**: For building AST nodes
|
|
477
|
+
- **Instruction pointer**: Sequential execution
|
|
478
|
+
- **Optimization passes**: Peephole optimization on compiled bytecode
|
|
479
|
+
|
|
480
|
+
## Instruction Set
|
|
481
|
+
|
|
482
|
+
The VM supports 28 instructions covering all PEG operations:
|
|
483
|
+
|
|
484
|
+
| Category | Instructions |
|
|
485
|
+
|----------|-------------|
|
|
486
|
+
| Matching | `Char`, `CharSet`, `String`, `Regex`, `Any`, `Custom` |
|
|
487
|
+
| Control Flow | `Jump`, `Call`, `Return`, `End` |
|
|
488
|
+
| Backtracking | `Choice`, `Commit`, `PartialCommit`, `BackCommit`, `Fail`, `FailTwice` |
|
|
489
|
+
| Captures | `OpenCapture`, `CloseCapture`, `FullCapture` |
|
|
490
|
+
| Tests | `TestChar`, `TestSet`, `TestAny` |
|
|
491
|
+
| Special | `Behind`, `Span`, `NoOp`, `PredChoice` |
|
|
492
|
+
|
|
493
|
+
# Architecture
|
|
494
|
+
|
|
495
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
496
|
+
│ PARSANOL-RS │
|
|
497
|
+
│ (Generic PEG Parser Library) │
|
|
498
|
+
├─────────────────────────────────────────────────────────────┤
|
|
499
|
+
│ • Parser combinators (PEG atoms) │
|
|
500
|
+
│ • Grammar representation │
|
|
501
|
+
│ • Packrat memoization │
|
|
502
|
+
│ • Arena allocation │
|
|
503
|
+
│ • Infix expression parsing │
|
|
504
|
+
│ • Rich error reporting (tree structure) │
|
|
505
|
+
│ • Transform DSL (pattern matching) │
|
|
506
|
+
│ • Derive macros for typed ASTs │
|
|
507
|
+
│ • Optional Ruby FFI / WASM bindings │
|
|
508
|
+
└─────────────────────────────────────────────────────────────┘
|
|
509
|
+
▲ ▲
|
|
510
|
+
│ (build ON TOP) │ (build ON TOP)
|
|
511
|
+
│ │
|
|
512
|
+
┌─────────┴──────────┐ ┌─────────┴─────────┐
|
|
513
|
+
│ parsanol-express │ │ Your Language │
|
|
514
|
+
│ (EXPRESS lexer) │ │ (Your DSL) │
|
|
515
|
+
└────────────────────┘ └───────────────────┘
|
|
516
|
+
|
|
517
|
+
> [!IMPORTANT]
|
|
518
|
+
> Parsanol-rs is a **GENERIC** parser library. It has no knowledge of
|
|
519
|
+
> any specific domain (EXPRESS, Ruby, JSON, YAML, etc.). Domain-specific
|
|
520
|
+
> parsers should be built ON TOP of this library.
|
|
521
|
+
|
|
522
|
+
# Workspace Structure
|
|
523
|
+
|
|
524
|
+
This repository uses a Cargo workspace with two crates:
|
|
525
|
+
|
|
526
|
+
```
|
|
527
|
+
parsanol-rs/
|
|
528
|
+
├── parsanol/ # Main parser library
|
|
529
|
+
│ ├── src/
|
|
530
|
+
│ └── Cargo.toml
|
|
531
|
+
├── parsanol-derive/ # Derive macros (always included)
|
|
532
|
+
│ ├── src/
|
|
533
|
+
│ └── Cargo.toml
|
|
534
|
+
├── examples/ # 39 example parsers
|
|
535
|
+
└── Cargo.toml # Workspace root
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
# Installation
|
|
539
|
+
|
|
540
|
+
Add this to your `Cargo.toml`:
|
|
541
|
+
|
|
542
|
+
```toml
|
|
543
|
+
[dependencies]
|
|
544
|
+
parsanol = "0.1"
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
The `parsanol-derive` crate is automatically included as a dependency,
|
|
548
|
+
providing the `#[derive(FromAst)]` macro for typed AST conversion.
|
|
549
|
+
|
|
550
|
+
## Optional Features
|
|
551
|
+
|
|
552
|
+
- `ruby` - Enable Ruby FFI bindings (requires `magnus`)
|
|
553
|
+
|
|
554
|
+
- `wasm` - Enable WebAssembly bindings (requires `wasm-bindgen`,
|
|
555
|
+
`js-sys`)
|
|
556
|
+
|
|
557
|
+
- `parallel` - Enable parallel parsing (requires `rayon`)
|
|
558
|
+
|
|
559
|
+
```toml
|
|
560
|
+
[dependencies]
|
|
561
|
+
parsanol = { version = "0.1", features = ["ruby", "parallel"] }
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
# Quick Start
|
|
565
|
+
|
|
566
|
+
## Basic Parsing
|
|
567
|
+
|
|
568
|
+
```rust
|
|
569
|
+
use parsanol::portable::{Grammar, PortableParser, AstArena, parser_dsl::*};
|
|
570
|
+
|
|
571
|
+
// Build a simple grammar
|
|
572
|
+
let grammar = GrammarBuilder::new()
|
|
573
|
+
.rule("greeting", str("hello").then(str("world")))
|
|
574
|
+
.build();
|
|
575
|
+
|
|
576
|
+
let input = "helloworld";
|
|
577
|
+
let mut arena = AstArena::for_input(input.len());
|
|
578
|
+
let mut parser = PortableParser::new(&grammar, input, &mut arena);
|
|
579
|
+
|
|
580
|
+
match parser.parse() {
|
|
581
|
+
Ok(ast) => println!("Parsed successfully: {:?}", ast),
|
|
582
|
+
Err(e) => println!("Parse error: {:?}", e),
|
|
583
|
+
}
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
## Calculator with Operator Precedence
|
|
587
|
+
|
|
588
|
+
```rust
|
|
589
|
+
use parsanol::portable::{
|
|
590
|
+
GrammarBuilder, PortableParser, AstArena, Grammar,
|
|
591
|
+
parser_dsl::{str, re, ref_, seq, choice, dynamic},
|
|
592
|
+
infix::{InfixBuilder, Assoc},
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
fn build_calculator_grammar() -> Grammar {
|
|
596
|
+
let mut builder = GrammarBuilder::new();
|
|
597
|
+
|
|
598
|
+
// Define atoms
|
|
599
|
+
builder = builder.rule("number", re(r"[0-9]+"));
|
|
600
|
+
builder = builder.rule("primary", choice(vec![
|
|
601
|
+
dynamic(seq(vec![
|
|
602
|
+
dynamic(str("(")),
|
|
603
|
+
dynamic(ref_("expr")),
|
|
604
|
+
dynamic(str(")")),
|
|
605
|
+
])),
|
|
606
|
+
dynamic(ref_("number")),
|
|
607
|
+
]));
|
|
608
|
+
|
|
609
|
+
// Build infix with precedence
|
|
610
|
+
let expr_atom = InfixBuilder::new()
|
|
611
|
+
.primary(ref_("primary"))
|
|
612
|
+
.op("*", 2, Assoc::Left)
|
|
613
|
+
.op("/", 2, Assoc::Left)
|
|
614
|
+
.op("+", 1, Assoc::Left)
|
|
615
|
+
.op("-", 1, Assoc::Left)
|
|
616
|
+
.build(&mut builder);
|
|
617
|
+
|
|
618
|
+
builder.update_rule("expr", expr_atom);
|
|
619
|
+
builder.build()
|
|
620
|
+
}
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
# Parser DSL
|
|
624
|
+
|
|
625
|
+
## Atom Types
|
|
626
|
+
|
|
627
|
+
| Atom | Description | Example |
|
|
628
|
+
|------|-------------|---------|
|
|
629
|
+
| `str("literal")` | Match exact string | `str("hello")` |
|
|
630
|
+
| `re("pattern")` | Match regex pattern | `re(r"[0-9]+")` |
|
|
631
|
+
| `any()` | Match any single character | `any()` |
|
|
632
|
+
| `ref_("rule")` | Reference to named rule | `ref_("expr")` |
|
|
633
|
+
| `seq([...])` | Sequence of atoms | `seq(vec![a, b, c])` |
|
|
634
|
+
| `choice([...])` | Alternative atoms | `choice(vec![a, b])` |
|
|
635
|
+
| `cut()` | Commit to this branch (prevent backtracking) | `cut()` |
|
|
636
|
+
| `capture("name", atom)` | Extract named value during parsing | `capture("id", re(r"[a-z]+"))` |
|
|
637
|
+
| `scope(atom)` | Create isolated capture context | `scope(seq([...]))` |
|
|
638
|
+
| `dynamic(callback)` | Runtime-determined parsing via callback | `dynamic(callback_id)` |
|
|
639
|
+
|
|
640
|
+
## Combinators
|
|
641
|
+
|
|
642
|
+
All atoms implement the `ParsletExt` trait with these methods:
|
|
643
|
+
|
|
644
|
+
```rust
|
|
645
|
+
use parsanol::portable::parser_dsl::*;
|
|
646
|
+
|
|
647
|
+
// Sequence: A >> B
|
|
648
|
+
let parser = str("hello").then(str("world"));
|
|
649
|
+
|
|
650
|
+
// Alternative: A | B
|
|
651
|
+
let parser = str("foo").or(str("bar"));
|
|
652
|
+
|
|
653
|
+
// Repetition
|
|
654
|
+
let parser = str("a").repeat(1, None); // One or more
|
|
655
|
+
let parser = str("a").repeat(0, Some(3)); // Zero to three
|
|
656
|
+
let parser = str("a").many(); // Zero or more
|
|
657
|
+
let parser = str("a").many1(); // One or more
|
|
658
|
+
let parser = str("a").optional(); // Zero or one
|
|
659
|
+
|
|
660
|
+
// Named capture
|
|
661
|
+
let parser = re(r"[0-9]+").label("number");
|
|
662
|
+
|
|
663
|
+
// Ignore (don't include in AST)
|
|
664
|
+
let parser = str(" ").ignore();
|
|
665
|
+
|
|
666
|
+
// Lookahead (don't consume)
|
|
667
|
+
let parser = str("hello").lookahead(); // Positive: must match
|
|
668
|
+
let parser = str("hello").not_ahead(); // Negative: must NOT match
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
## Grammar Macro
|
|
672
|
+
|
|
673
|
+
For declarative grammar definition:
|
|
674
|
+
|
|
675
|
+
```rust
|
|
676
|
+
use parsanol::portable::parser_dsl::grammar;
|
|
677
|
+
|
|
678
|
+
let grammar = grammar! {
|
|
679
|
+
"hello" => str("hello"),
|
|
680
|
+
"world" => str("world"),
|
|
681
|
+
"greeting" => ref_("hello").then(ref_("world")),
|
|
682
|
+
};
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
# Capture Atoms
|
|
686
|
+
|
|
687
|
+
Capture atoms extract named values during parsing, similar to regex named groups. They work with all backends (Packrat, Bytecode, Streaming).
|
|
688
|
+
|
|
689
|
+
## Basic Usage
|
|
690
|
+
|
|
691
|
+
```rust
|
|
692
|
+
use parsanol::portable::{
|
|
693
|
+
parser_dsl::{capture, dynamic, re, seq, GrammarBuilder},
|
|
694
|
+
PortableParser, AstArena,
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
let grammar = GrammarBuilder::new()
|
|
698
|
+
.rule("greeting", seq(vec![
|
|
699
|
+
capture("word", dynamic(re(r"[a-zA-Z]+"))),
|
|
700
|
+
]))
|
|
701
|
+
.build();
|
|
702
|
+
|
|
703
|
+
let mut arena = AstArena::for_input(64);
|
|
704
|
+
let mut parser = PortableParser::packrat(grammar);
|
|
705
|
+
let result = parser.parse_from_pos(0, "hello world", &mut arena)?;
|
|
706
|
+
|
|
707
|
+
// Access captures
|
|
708
|
+
if let Some(text) = result.get_capture("word", "hello world") {
|
|
709
|
+
println!("Captured: {}", text); // Prints: "hello"
|
|
710
|
+
}
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
## Capture API
|
|
714
|
+
|
|
715
|
+
```rust
|
|
716
|
+
// Get a single capture by name
|
|
717
|
+
let value = result.get_capture("name", input);
|
|
718
|
+
|
|
719
|
+
// Get all capture names
|
|
720
|
+
for name in result.capture_names() {
|
|
721
|
+
println!("Capture: {}", name);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Check if capture exists
|
|
725
|
+
if result.has_capture("name") {
|
|
726
|
+
// ...
|
|
727
|
+
}
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
## Backend Compatibility
|
|
731
|
+
|
|
732
|
+
| Backend | Capture Support | Notes |
|
|
733
|
+
|---------|-----------------|-------|
|
|
734
|
+
| Packrat | Full | Native support |
|
|
735
|
+
| Bytecode | Full | Uses capture instructions |
|
|
736
|
+
| Streaming | Full | Captures persist across chunks |
|
|
737
|
+
|
|
738
|
+
# Scope Atoms
|
|
739
|
+
|
|
740
|
+
Scope atoms create isolated capture contexts. Captures made inside a scope are discarded when the scope exits, preventing pollution of the parent context.
|
|
741
|
+
|
|
742
|
+
## Use Cases
|
|
743
|
+
|
|
744
|
+
- Nested parsing where inner captures shouldn't affect outer state
|
|
745
|
+
- Repetitive patterns where each iteration starts fresh
|
|
746
|
+
- Context isolation in recursive grammars
|
|
747
|
+
|
|
748
|
+
## Basic Usage
|
|
749
|
+
|
|
750
|
+
```rust
|
|
751
|
+
use parsanol::portable::parser_dsl::{scope, seq, capture, dynamic, re, GrammarBuilder};
|
|
752
|
+
|
|
753
|
+
let grammar = GrammarBuilder::new()
|
|
754
|
+
.rule("outer", seq(vec![
|
|
755
|
+
capture("outer_name", dynamic(re(r"[a-z]+"))),
|
|
756
|
+
scope(seq(vec![
|
|
757
|
+
capture("inner_name", dynamic(re(r"[0-9]+"))),
|
|
758
|
+
])),
|
|
759
|
+
// "inner_name" is NOT available here
|
|
760
|
+
]))
|
|
761
|
+
.build();
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
# Dynamic Atoms
|
|
765
|
+
|
|
766
|
+
Dynamic atoms enable runtime-determined parsing via registered callbacks. This allows context-sensitive parsing where the grammar itself depends on input or previously captured values.
|
|
767
|
+
|
|
768
|
+
## Registering a Callback
|
|
769
|
+
|
|
770
|
+
```rust
|
|
771
|
+
use parsanol::portable::{
|
|
772
|
+
Grammar, Atom, Parser,
|
|
773
|
+
dynamic::{DynamicCallback, DynamicContext, register_dynamic_callback},
|
|
774
|
+
parser_dsl::*,
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
struct KeywordCallback;
|
|
778
|
+
|
|
779
|
+
impl DynamicCallback for KeywordCallback {
|
|
780
|
+
fn call(&self, ctx: &DynamicContext) -> Option<Atom> {
|
|
781
|
+
// Access current position
|
|
782
|
+
let pos = ctx.pos();
|
|
783
|
+
// Access input
|
|
784
|
+
let input = ctx.input();
|
|
785
|
+
// Access captures made so far
|
|
786
|
+
if let Some(lang) = ctx.get_capture("language") {
|
|
787
|
+
match lang {
|
|
788
|
+
"ruby" => Some(Atom::Str { pattern: "def".into() }),
|
|
789
|
+
"python" => Some(Atom::Str { pattern: "lambda".into() }),
|
|
790
|
+
_ => None,
|
|
791
|
+
}
|
|
792
|
+
} else {
|
|
793
|
+
None
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
fn description(&self) -> &str {
|
|
798
|
+
"keyword_callback"
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
let callback_id = register_dynamic_callback(Box::new(KeywordCallback));
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
## Using Dynamic Atoms in Grammars
|
|
806
|
+
|
|
807
|
+
```rust
|
|
808
|
+
let grammar = GrammarBuilder::new()
|
|
809
|
+
.rule("keyword", dynamic_with_id(callback_id))
|
|
810
|
+
.build();
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
## Backend Compatibility
|
|
814
|
+
|
|
815
|
+
| Backend | Dynamic Support | Notes |
|
|
816
|
+
|---------|-----------------|-------|
|
|
817
|
+
| Packrat | Full | Native support (recommended) |
|
|
818
|
+
| Bytecode | Fallback | Uses Packrat internally |
|
|
819
|
+
| Streaming | Fallback | Uses Packrat internally |
|
|
820
|
+
|
|
821
|
+
**Note:** For heavy dynamic atom usage, prefer the Packrat backend for best performance.
|
|
822
|
+
|
|
823
|
+
# Streaming with Captures
|
|
824
|
+
|
|
825
|
+
The streaming parser supports captures while maintaining bounded memory usage. Captures persist across streaming parse operations.
|
|
826
|
+
|
|
827
|
+
## Basic Usage
|
|
828
|
+
|
|
829
|
+
```rust
|
|
830
|
+
use parsanol::portable::{
|
|
831
|
+
parser_dsl::{capture, dynamic, re, GrammarBuilder},
|
|
832
|
+
streaming::{StreamingParser, ChunkConfig},
|
|
833
|
+
arena::AstArena,
|
|
834
|
+
};
|
|
835
|
+
use std::io::Cursor;
|
|
836
|
+
|
|
837
|
+
let grammar = GrammarBuilder::new()
|
|
838
|
+
.rule("word", capture("word", dynamic(re(r"[a-zA-Z]+"))))
|
|
839
|
+
.build();
|
|
840
|
+
|
|
841
|
+
let config = ChunkConfig {
|
|
842
|
+
chunk_size: 65536, // 64 KB chunks
|
|
843
|
+
window_size: 2, // Keep 2 chunks in memory
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
let mut parser = StreamingParser::new(&grammar, config);
|
|
847
|
+
let mut arena = AstArena::for_input(65536);
|
|
848
|
+
let mut cursor = Cursor::new(input.as_bytes());
|
|
849
|
+
|
|
850
|
+
let result = parser.parse_from_reader(&mut cursor, &mut arena)?;
|
|
851
|
+
|
|
852
|
+
if let Some(captures) = &result.capture_state {
|
|
853
|
+
for name in captures.names() {
|
|
854
|
+
if let Some(value) = captures.get(&name) {
|
|
855
|
+
println!("{} = {:?}", name, value.get_text(input));
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
## Chunk Configuration
|
|
862
|
+
|
|
863
|
+
| Preset | Chunk Size | Window | Use Case |
|
|
864
|
+
|--------|------------|--------|----------|
|
|
865
|
+
| `small()` | 16 KB | 2 | Real-time feeds |
|
|
866
|
+
| `medium()` | 64 KB | 3 | Default |
|
|
867
|
+
| `large()` | 256 KB | 4 | Log files |
|
|
868
|
+
| `huge()` | 1 MB | 5 | Large files |
|
|
869
|
+
|
|
870
|
+
## Performance Notes
|
|
871
|
+
|
|
872
|
+
- Memory: O(chunk_size × window_size + capture_state)
|
|
873
|
+
- Captures accumulate during parse, available at end
|
|
874
|
+
- For very large captures, use `reset()` to process incrementally
|
|
875
|
+
|
|
876
|
+
# Transform System
|
|
877
|
+
|
|
878
|
+
The transform system converts generic parse trees into typed Rust data
|
|
879
|
+
structures, similar to Parslet’s transformation system.
|
|
880
|
+
|
|
881
|
+
## Value Types
|
|
882
|
+
|
|
883
|
+
The `Value` enum represents transformed data:
|
|
884
|
+
|
|
885
|
+
```rust
|
|
886
|
+
pub enum Value {
|
|
887
|
+
Nil,
|
|
888
|
+
Bool(bool),
|
|
889
|
+
Int(i64),
|
|
890
|
+
Float(f64),
|
|
891
|
+
String(String),
|
|
892
|
+
Array(Vec<Value>),
|
|
893
|
+
Hash(HashMap<String, Value>),
|
|
894
|
+
}
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
## Basic Transformations
|
|
898
|
+
|
|
899
|
+
```rust
|
|
900
|
+
use parsanol::portable::transform::{Transform, Value, TransformError};
|
|
901
|
+
|
|
902
|
+
let transform = Transform::new()
|
|
903
|
+
// Transform "int" captures by doubling
|
|
904
|
+
.rule("int", |v| {
|
|
905
|
+
let n = v.as_int().ok_or_else(|| TransformError::Custom("not int".into()))?;
|
|
906
|
+
Ok(Value::int(n * 2))
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
let value = Value::hash(vec![("int", Value::int(21))]);
|
|
910
|
+
let result = transform.apply(&value)?;
|
|
911
|
+
assert_eq!(result.as_int(), Some(42));
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
## Pattern Matching
|
|
915
|
+
|
|
916
|
+
Pattern-based transformations similar to Parslet:
|
|
917
|
+
|
|
918
|
+
```rust
|
|
919
|
+
use parsanol::portable::transform::{Transform, Pattern, Value};
|
|
920
|
+
|
|
921
|
+
let transform = Transform::new()
|
|
922
|
+
// Match hash with specific fields
|
|
923
|
+
.pattern(
|
|
924
|
+
Pattern::hash()
|
|
925
|
+
.field("left", "l")
|
|
926
|
+
.field("op", Pattern::str("+"))
|
|
927
|
+
.field("right", "r"),
|
|
928
|
+
|bindings| {
|
|
929
|
+
let l = bindings.get_int("l")?;
|
|
930
|
+
let r = bindings.get_int("r")?;
|
|
931
|
+
Ok(Value::int(l + r))
|
|
932
|
+
}
|
|
933
|
+
);
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
## Pattern Types
|
|
937
|
+
|
|
938
|
+
| Pattern | Description | Example |
|
|
939
|
+
|----|----|----|
|
|
940
|
+
| `Pattern::simple("x")` | Match any leaf value and bind to variable | `Pattern::simple("n")` matches `42` |
|
|
941
|
+
| `Pattern::str("value")` | Match a specific string value | `Pattern::str("+")` matches `"+"` |
|
|
942
|
+
| `Pattern::int(n)` | Match a specific integer | `Pattern::int(42)` matches `42` |
|
|
943
|
+
| `Pattern::sequence("x")` | Match an array and bind to variable | `Pattern::sequence("items")` |
|
|
944
|
+
| `Pattern::subtree("x")` | Match anything and bind to variable | `Pattern::subtree("node")` |
|
|
945
|
+
| `Pattern::hash()` | Match a hash with specific fields | See example above |
|
|
946
|
+
|
|
947
|
+
## Converting AST to Value
|
|
948
|
+
|
|
949
|
+
```rust
|
|
950
|
+
use parsanol::portable::transform::{ast_to_value, Value};
|
|
951
|
+
|
|
952
|
+
// After parsing
|
|
953
|
+
let ast = parser.parse()?;
|
|
954
|
+
let value = ast_to_value(&ast, &arena, input);
|
|
955
|
+
|
|
956
|
+
// Now apply transforms
|
|
957
|
+
let result = transform.apply(&value)?;
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
# Derive Macros
|
|
961
|
+
|
|
962
|
+
The `FromAst` derive macro automatically generates code to convert `Value`
|
|
963
|
+
types into typed Rust structs and enums. This eliminates boilerplate code
|
|
964
|
+
for AST transformation.
|
|
965
|
+
|
|
966
|
+
## Basic Usage
|
|
967
|
+
|
|
968
|
+
```rust
|
|
969
|
+
use parsanol::derive::FromAst;
|
|
970
|
+
use parsanol::portable::transform::Value;
|
|
971
|
+
|
|
972
|
+
#[derive(FromAst, Debug)]
|
|
973
|
+
pub enum Expr {
|
|
974
|
+
#[parsanol(tag = "number")]
|
|
975
|
+
Number(i64),
|
|
976
|
+
|
|
977
|
+
#[parsanol(tag = "binop")]
|
|
978
|
+
BinOp {
|
|
979
|
+
left: Box<Expr>,
|
|
980
|
+
op: String,
|
|
981
|
+
right: Box<Expr>,
|
|
982
|
+
},
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Convert Value to typed Expr
|
|
986
|
+
let value: Value = /* ... parsed value ... */;
|
|
987
|
+
let expr: Expr = value.try_into()?;
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
## Container Attributes
|
|
991
|
+
|
|
992
|
+
| Attribute | Description |
|
|
993
|
+
|-----------|-------------|
|
|
994
|
+
| `#[parsanol(rule = "name")]` | Specify the grammar rule name |
|
|
995
|
+
|
|
996
|
+
## Variant Attributes (for enums)
|
|
997
|
+
|
|
998
|
+
| Attribute | Description |
|
|
999
|
+
|-----------|-------------|
|
|
1000
|
+
| `#[parsanol(tag = "literal")]` | Match by literal tag string |
|
|
1001
|
+
| `#[parsanol(tag_expr = expr)]` | Match by expression (for dynamic tags) |
|
|
1002
|
+
|
|
1003
|
+
## Field Attributes
|
|
1004
|
+
|
|
1005
|
+
| Attribute | Description |
|
|
1006
|
+
|-----------|-------------|
|
|
1007
|
+
| `#[parsanol(field = "name")]` | Map to different hash field name |
|
|
1008
|
+
| `#[parsanol(default)]` | Use `Default::default()` if missing |
|
|
1009
|
+
| `#[parsanol(default = expr)]` | Use expression if missing |
|
|
1010
|
+
|
|
1011
|
+
## Complete Example
|
|
1012
|
+
|
|
1013
|
+
```rust
|
|
1014
|
+
use parsanol::derive::FromAst;
|
|
1015
|
+
use parsanol::portable::transform::Value;
|
|
1016
|
+
|
|
1017
|
+
#[derive(FromAst, Debug)]
|
|
1018
|
+
#[parsanol(rule = "statement")]
|
|
1019
|
+
pub enum Statement {
|
|
1020
|
+
#[parsanol(tag = "assignment")]
|
|
1021
|
+
Assignment {
|
|
1022
|
+
#[parsanol(field = "name")]
|
|
1023
|
+
variable: String,
|
|
1024
|
+
value: Box<Expr>,
|
|
1025
|
+
},
|
|
1026
|
+
|
|
1027
|
+
#[parsanol(tag = "return")]
|
|
1028
|
+
Return {
|
|
1029
|
+
#[parsanol(default)]
|
|
1030
|
+
value: Option<Box<Expr>>,
|
|
1031
|
+
},
|
|
1032
|
+
|
|
1033
|
+
#[parsanol(tag = "if")]
|
|
1034
|
+
If {
|
|
1035
|
+
condition: Box<Expr>,
|
|
1036
|
+
then_block: Vec<Statement>,
|
|
1037
|
+
#[parsanol(default)]
|
|
1038
|
+
else_block: Option<Vec<Statement>>,
|
|
1039
|
+
},
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Usage
|
|
1043
|
+
fn parse_statement(value: Value) -> Result<Statement, parsanol::derive::FromAstError> {
|
|
1044
|
+
value.try_into()
|
|
1045
|
+
}
|
|
1046
|
+
```
|
|
1047
|
+
|
|
1048
|
+
## Single-Field Tuple Structs
|
|
1049
|
+
|
|
1050
|
+
Single-field tuple structs automatically get transparent conversion:
|
|
1051
|
+
|
|
1052
|
+
```rust
|
|
1053
|
+
#[derive(FromAst)]
|
|
1054
|
+
pub struct Identifier(pub String);
|
|
1055
|
+
|
|
1056
|
+
// Value::String("foo") directly converts to Identifier("foo")
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
## Error Handling
|
|
1060
|
+
|
|
1061
|
+
```rust
|
|
1062
|
+
use parsanol::derive::FromAstError;
|
|
1063
|
+
|
|
1064
|
+
match value.try_into() {
|
|
1065
|
+
Ok(expr) => println!("Parsed: {:?}", expr),
|
|
1066
|
+
Err(FromAstError::MissingField(field)) => {
|
|
1067
|
+
eprintln!("Missing field: {}", field);
|
|
1068
|
+
}
|
|
1069
|
+
Err(FromAstError::UnknownTag) => {
|
|
1070
|
+
eprintln!("Unknown tag in enum");
|
|
1071
|
+
}
|
|
1072
|
+
Err(e) => eprintln!("Conversion error: {}", e),
|
|
1073
|
+
}
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
# Streaming Builder
|
|
1077
|
+
|
|
1078
|
+
The streaming builder API allows single-pass parsing without
|
|
1079
|
+
intermediate AST construction. This is ideal for:
|
|
1080
|
+
|
|
1081
|
+
- Maximum performance (eliminates AST allocation)
|
|
1082
|
+
|
|
1083
|
+
- Custom output formats
|
|
1084
|
+
|
|
1085
|
+
- Memory-constrained environments
|
|
1086
|
+
|
|
1087
|
+
## Implementing StreamingBuilder
|
|
1088
|
+
|
|
1089
|
+
```rust
|
|
1090
|
+
use parsanol::portable::streaming_builder::{StreamingBuilder, BuildResult, BuildError};
|
|
1091
|
+
|
|
1092
|
+
// Custom builder that collects all strings
|
|
1093
|
+
struct StringCollector {
|
|
1094
|
+
strings: Vec<String>,
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
impl StreamingBuilder for StringCollector {
|
|
1098
|
+
type Output = Vec<String>;
|
|
1099
|
+
|
|
1100
|
+
fn on_string(&mut self, value: &str, _offset: usize, _length: usize) -> BuildResult<()> {
|
|
1101
|
+
self.strings.push(value.to_string());
|
|
1102
|
+
Ok(())
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
fn finish(&mut self) -> BuildResult<Self::Output> {
|
|
1106
|
+
Ok(std::mem::take(&mut self.strings))
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
## Using parse_with_builder
|
|
1112
|
+
|
|
1113
|
+
```rust
|
|
1114
|
+
use parsanol::portable::{Grammar, PortableParser, AstArena};
|
|
1115
|
+
|
|
1116
|
+
let grammar = /* ... */;
|
|
1117
|
+
let input = "hello world";
|
|
1118
|
+
let mut arena = AstArena::for_input(input.len());
|
|
1119
|
+
let mut parser = PortableParser::new(&grammar, input, &mut arena);
|
|
1120
|
+
|
|
1121
|
+
// Create builder
|
|
1122
|
+
let mut builder = StringCollector { strings: vec![] };
|
|
1123
|
+
|
|
1124
|
+
// Parse with streaming builder
|
|
1125
|
+
let result = parser.parse_with_builder(&mut builder)?;
|
|
1126
|
+
// result: Vec<String>
|
|
1127
|
+
```
|
|
1128
|
+
|
|
1129
|
+
## Built-in Builders
|
|
1130
|
+
|
|
1131
|
+
Several useful builders are provided:
|
|
1132
|
+
|
|
1133
|
+
| Builder | Description |
|
|
1134
|
+
|--------------------------|----------------------------------------------|
|
|
1135
|
+
| `DebugBuilder` | Collects all events as strings for debugging |
|
|
1136
|
+
| `BuilderStringCollector` | Collects all string values |
|
|
1137
|
+
| `BuilderNodeCounter` | Counts nodes by type |
|
|
1138
|
+
|
|
1139
|
+
## Ruby Integration
|
|
1140
|
+
|
|
1141
|
+
The streaming builder works with Ruby callbacks via FFI:
|
|
1142
|
+
|
|
1143
|
+
```ruby
|
|
1144
|
+
require 'parsanol'
|
|
1145
|
+
|
|
1146
|
+
class MyBuilder
|
|
1147
|
+
include Parsanol::BuilderCallbacks
|
|
1148
|
+
|
|
1149
|
+
def initialize
|
|
1150
|
+
@strings = []
|
|
1151
|
+
end
|
|
1152
|
+
|
|
1153
|
+
def on_string(value, offset, length)
|
|
1154
|
+
@strings << value
|
|
1155
|
+
end
|
|
1156
|
+
|
|
1157
|
+
def finish
|
|
1158
|
+
@strings
|
|
1159
|
+
end
|
|
1160
|
+
end
|
|
1161
|
+
|
|
1162
|
+
builder = MyBuilder.new
|
|
1163
|
+
result = Parsanol::Native.parse_with_builder(grammar_json, input, builder)
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
# Parallel Parsing
|
|
1167
|
+
|
|
1168
|
+
Parse multiple inputs in parallel using rayon for linear speedup on
|
|
1169
|
+
multi-core systems.
|
|
1170
|
+
|
|
1171
|
+
## Enabling Parallel Feature
|
|
1172
|
+
|
|
1173
|
+
```toml
|
|
1174
|
+
[dependencies]
|
|
1175
|
+
parsanol = { version = "0.1", features = ["parallel"] }
|
|
1176
|
+
```
|
|
1177
|
+
|
|
1178
|
+
## Batch Parallel Parsing
|
|
1179
|
+
|
|
1180
|
+
```rust
|
|
1181
|
+
use parsanol::portable::{Grammar, parse_batch_parallel};
|
|
1182
|
+
|
|
1183
|
+
let grammar = /* ... */;
|
|
1184
|
+
let inputs = vec!["file1.exp", "file2.exp", "file3.exp"];
|
|
1185
|
+
|
|
1186
|
+
// Parse all inputs in parallel
|
|
1187
|
+
let results = parse_batch_parallel(&grammar, &inputs);
|
|
1188
|
+
|
|
1189
|
+
// Results are in same order as inputs
|
|
1190
|
+
for (i, result) in results.iter().enumerate() {
|
|
1191
|
+
match result {
|
|
1192
|
+
Ok(ast) => println!("File {} parsed successfully", i),
|
|
1193
|
+
Err(e) => eprintln!("File {} failed: {}", i, e),
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
```
|
|
1197
|
+
|
|
1198
|
+
## Parallel Configuration
|
|
1199
|
+
|
|
1200
|
+
```rust
|
|
1201
|
+
use parsanol::portable::parallel::{parse_batch_parallel, ParallelConfig};
|
|
1202
|
+
|
|
1203
|
+
let config = ParallelConfig::new()
|
|
1204
|
+
.with_num_threads(4) // Use 4 threads
|
|
1205
|
+
.with_min_chunk_size(10); // Minimum inputs per thread
|
|
1206
|
+
|
|
1207
|
+
let results = parse_batch_parallel(&grammar, &inputs);
|
|
1208
|
+
```
|
|
1209
|
+
|
|
1210
|
+
## Performance
|
|
1211
|
+
|
|
1212
|
+
| Scenario | Speedup |
|
|
1213
|
+
|--------------------|----------------------------------------|
|
|
1214
|
+
| 8 cores, 100 files | ~8x faster than sequential |
|
|
1215
|
+
| 4 cores, 50 files | ~4x faster than sequential |
|
|
1216
|
+
| Single core | Same as sequential (graceful fallback) |
|
|
1217
|
+
|
|
1218
|
+
When the `parallel` feature is not enabled, the functions fall back to
|
|
1219
|
+
sequential parsing automatically.
|
|
1220
|
+
|
|
1221
|
+
# Infix Expression Parsing
|
|
1222
|
+
|
|
1223
|
+
Built-in support for parsing infix expressions with operator precedence
|
|
1224
|
+
and associativity.
|
|
1225
|
+
|
|
1226
|
+
## Using InfixBuilder
|
|
1227
|
+
|
|
1228
|
+
```rust
|
|
1229
|
+
use parsanol::portable::infix::{InfixBuilder, Assoc};
|
|
1230
|
+
|
|
1231
|
+
let mut builder = GrammarBuilder::new();
|
|
1232
|
+
|
|
1233
|
+
let expr_idx = InfixBuilder::new()
|
|
1234
|
+
.primary(ref_("atom")) // Base expression (numbers, parens)
|
|
1235
|
+
.op("*", 2, Assoc::Left) // Higher precedence
|
|
1236
|
+
.op("/", 2, Assoc::Left)
|
|
1237
|
+
.op("+", 1, Assoc::Left) // Lower precedence
|
|
1238
|
+
.op("-", 1, Assoc::Left)
|
|
1239
|
+
.op("^", 3, Assoc::Right) // Right-associative
|
|
1240
|
+
.build(&mut builder);
|
|
1241
|
+
```
|
|
1242
|
+
|
|
1243
|
+
## Associativity
|
|
1244
|
+
|
|
1245
|
+
| Associativity | Meaning | Example |
|
|
1246
|
+
|----|----|----|
|
|
1247
|
+
| `Assoc::Left` | Left-to-right evaluation | `a` `-` `b` `-` `c` = `(a` `-` `b)` `-` `c` |
|
|
1248
|
+
| `Assoc::Right` | Right-to-left evaluation | `a` `=` `b` `=` `c` = `a` `=` `(b` `=` `c)` |
|
|
1249
|
+
| `Assoc::NonAssoc` | Cannot chain | `a` `<` `b` `<` `c` is an error |
|
|
1250
|
+
|
|
1251
|
+
# Rich Error Reporting
|
|
1252
|
+
|
|
1253
|
+
Tree-structured error messages similar to Parslet for better debugging.
|
|
1254
|
+
|
|
1255
|
+
## Basic Usage
|
|
1256
|
+
|
|
1257
|
+
```rust
|
|
1258
|
+
use parsanol::portable::error::{RichError, ErrorBuilder, Span};
|
|
1259
|
+
|
|
1260
|
+
// Create rich errors
|
|
1261
|
+
let error = ErrorBuilder::new("Failed to parse expression")
|
|
1262
|
+
.at(10, 2, 5) // offset, line, column
|
|
1263
|
+
.context("expression")
|
|
1264
|
+
.child(
|
|
1265
|
+
ErrorBuilder::new("Expected '+' or '-'")
|
|
1266
|
+
.at(10, 2, 5)
|
|
1267
|
+
.build(),
|
|
1268
|
+
)
|
|
1269
|
+
.build();
|
|
1270
|
+
|
|
1271
|
+
// Print as ASCII tree
|
|
1272
|
+
println!("{}", error.ascii_tree());
|
|
1273
|
+
```
|
|
1274
|
+
|
|
1275
|
+
## Example Output
|
|
1276
|
+
|
|
1277
|
+
Error at line 3, column 5:
|
|
1278
|
+
`- Failed to parse expression (in expression)
|
|
1279
|
+
`- Expected '+' or '-'
|
|
1280
|
+
|
|
1281
|
+
## Source Context
|
|
1282
|
+
|
|
1283
|
+
```rust
|
|
1284
|
+
// Format error with source code context
|
|
1285
|
+
let formatted = error.format_with_source(input);
|
|
1286
|
+
println!("{}", formatted);
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
Output:
|
|
1290
|
+
|
|
1291
|
+
Error at line 3, column 5:
|
|
1292
|
+
let x = foo bar
|
|
1293
|
+
^
|
|
1294
|
+
`- Failed to parse expression (in expression)
|
|
1295
|
+
`- Expected '+' or '-'
|
|
1296
|
+
|
|
1297
|
+
# Source Location Tracking
|
|
1298
|
+
|
|
1299
|
+
Track source positions through the parsing and transformation pipeline.
|
|
1300
|
+
|
|
1301
|
+
## Using SourceSpan
|
|
1302
|
+
|
|
1303
|
+
```rust
|
|
1304
|
+
use parsanol::portable::source_location::{SourceSpan, SourcePosition};
|
|
1305
|
+
use parsanol::portable::transform::{ast_to_value_with_span};
|
|
1306
|
+
|
|
1307
|
+
// Create a span from offsets
|
|
1308
|
+
let span = SourceSpan::from_offsets(input, 10, 20);
|
|
1309
|
+
println!("Line {}, Column {}", span.start.line, span.start.column);
|
|
1310
|
+
|
|
1311
|
+
// Merge adjacent spans
|
|
1312
|
+
let merged = span1.merge(&span2);
|
|
1313
|
+
|
|
1314
|
+
// Check overlap
|
|
1315
|
+
if span1.overlaps(&span2) {
|
|
1316
|
+
// Spans overlap
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// Transform AST with source spans preserved
|
|
1320
|
+
let (value, spans) = ast_to_value_with_span(&ast, &arena, input);
|
|
1321
|
+
```
|
|
1322
|
+
|
|
1323
|
+
# Grammar Composition
|
|
1324
|
+
|
|
1325
|
+
Build complex grammars by importing and composing smaller grammars.
|
|
1326
|
+
|
|
1327
|
+
## Importing Grammars
|
|
1328
|
+
|
|
1329
|
+
```rust
|
|
1330
|
+
use parsanol::portable::parser_dsl::*;
|
|
1331
|
+
|
|
1332
|
+
let mut builder = GrammarBuilder::new();
|
|
1333
|
+
|
|
1334
|
+
// Import another grammar with a prefix
|
|
1335
|
+
builder.import(&expression_grammar, Some("expr"));
|
|
1336
|
+
builder.import(&type_grammar, Some("type"));
|
|
1337
|
+
|
|
1338
|
+
// Reference imported rules
|
|
1339
|
+
let combined = seq(vec![
|
|
1340
|
+
ref_("expr:root"), // References expression_grammar's root
|
|
1341
|
+
str(":"),
|
|
1342
|
+
ref_("type:root"), // References type_grammar's root
|
|
1343
|
+
]);
|
|
1344
|
+
|
|
1345
|
+
builder.rule("typed_expr", combined);
|
|
1346
|
+
let grammar = builder.build();
|
|
1347
|
+
```
|
|
1348
|
+
|
|
1349
|
+
# Ruby FFI
|
|
1350
|
+
|
|
1351
|
+
Parsanol-rs can be compiled as a Ruby extension for use with
|
|
1352
|
+
parsanol-ruby.
|
|
1353
|
+
|
|
1354
|
+
## Features
|
|
1355
|
+
|
|
1356
|
+
The Ruby FFI provides:
|
|
1357
|
+
|
|
1358
|
+
- **17x faster** parsing than pure Ruby (Parslet)
|
|
1359
|
+
- **Single `parse()` API** - no confusing options
|
|
1360
|
+
- **Lazy line/column** - zero overhead unless needed
|
|
1361
|
+
- **Batch FFI** - efficient u64 array transfer across language boundary
|
|
1362
|
+
|
|
1363
|
+
## Building for Ruby
|
|
1364
|
+
|
|
1365
|
+
> **Ruby 4.0 Support**: This version uses unreleased magnus 0.9.0 and rb-sys HEAD
|
|
1366
|
+
> for Ruby 4.0 compatibility. The workspace `Cargo.toml` patches rb-sys automatically.
|
|
1367
|
+
|
|
1368
|
+
```bash
|
|
1369
|
+
# Build with Ruby support
|
|
1370
|
+
cargo build --features ruby
|
|
1371
|
+
|
|
1372
|
+
# The resulting library can be loaded as a Ruby extension
|
|
1373
|
+
```
|
|
1374
|
+
|
|
1375
|
+
## Ruby API
|
|
1376
|
+
|
|
1377
|
+
```ruby
|
|
1378
|
+
require 'parsanol/native'
|
|
1379
|
+
|
|
1380
|
+
# Serialize grammar once
|
|
1381
|
+
grammar = str('hello').as(:greeting) >> str(' ').maybe >> match('[a-z]').repeat(1).as(:name)
|
|
1382
|
+
grammar_json = Parsanol::Native.serialize_grammar(grammar)
|
|
1383
|
+
|
|
1384
|
+
# Parse - simple and clean
|
|
1385
|
+
result = Parsanol::Native.parse(grammar_json, "hello world")
|
|
1386
|
+
# => {greeting: "hello"@0, name: "world"@6}
|
|
1387
|
+
|
|
1388
|
+
# Line/column available when needed (computed lazily)
|
|
1389
|
+
result[:greeting].line_and_column # => [1, 1]
|
|
1390
|
+
result[:name].line_and_column # => [1, 7]
|
|
1391
|
+
```
|
|
1392
|
+
|
|
1393
|
+
## Lazy Line/Column
|
|
1394
|
+
|
|
1395
|
+
Slice objects support lazy line/column computation:
|
|
1396
|
+
|
|
1397
|
+
- `slice.offset` - character position (always available, zero cost)
|
|
1398
|
+
- `slice.content` - string value (always available, zero cost)
|
|
1399
|
+
- `slice.line_and_column` - [line, column] tuple (computed lazily, cached)
|
|
1400
|
+
|
|
1401
|
+
This provides **zero overhead** for users who don't need position info,
|
|
1402
|
+
while keeping line/column **always available** when needed.
|
|
1403
|
+
|
|
1404
|
+
## Streaming Builder (Ruby)
|
|
1405
|
+
|
|
1406
|
+
For maximum performance, use the streaming builder API:
|
|
1407
|
+
|
|
1408
|
+
```ruby
|
|
1409
|
+
require 'parsanol'
|
|
1410
|
+
|
|
1411
|
+
# Define a builder class
|
|
1412
|
+
class StringCollector
|
|
1413
|
+
include Parsanol::BuilderCallbacks
|
|
1414
|
+
|
|
1415
|
+
def initialize
|
|
1416
|
+
@strings = []
|
|
1417
|
+
end
|
|
1418
|
+
|
|
1419
|
+
def on_string(value, offset, length)
|
|
1420
|
+
@strings << value
|
|
1421
|
+
end
|
|
1422
|
+
|
|
1423
|
+
def on_int(value)
|
|
1424
|
+
@strings << value.to_s
|
|
1425
|
+
end
|
|
1426
|
+
|
|
1427
|
+
def finish
|
|
1428
|
+
@strings
|
|
1429
|
+
end
|
|
1430
|
+
end
|
|
1431
|
+
|
|
1432
|
+
# Parse with streaming builder
|
|
1433
|
+
builder = StringCollector.new
|
|
1434
|
+
result = Parsanol::Native.parse_with_builder(grammar_json, input, builder)
|
|
1435
|
+
# result: ["42", "+", "8"]
|
|
1436
|
+
```
|
|
1437
|
+
|
|
1438
|
+
See [parsanol-ruby](https://github.com/parsanol/parsanol-ruby) for full
|
|
1439
|
+
documentation.
|
|
1440
|
+
|
|
1441
|
+
# WASM Support
|
|
1442
|
+
|
|
1443
|
+
Parsanol-rs can be compiled to WebAssembly for use in browsers or
|
|
1444
|
+
Node.js.
|
|
1445
|
+
|
|
1446
|
+
## Building for WASM
|
|
1447
|
+
|
|
1448
|
+
```bash
|
|
1449
|
+
# Install wasm-pack
|
|
1450
|
+
cargo install wasm-pack
|
|
1451
|
+
|
|
1452
|
+
# Build for web
|
|
1453
|
+
wasm-pack build --features wasm --target web
|
|
1454
|
+
```
|
|
1455
|
+
|
|
1456
|
+
## JavaScript API
|
|
1457
|
+
|
|
1458
|
+
```javascript
|
|
1459
|
+
import { Parser, Grammar } from 'parsanol';
|
|
1460
|
+
|
|
1461
|
+
const grammar = Grammar.fromJson({
|
|
1462
|
+
atoms: [
|
|
1463
|
+
{ Str: { pattern: "hello" } }
|
|
1464
|
+
],
|
|
1465
|
+
root: 0
|
|
1466
|
+
});
|
|
1467
|
+
|
|
1468
|
+
const parser = new Parser(grammar);
|
|
1469
|
+
const result = parser.parse("hello");
|
|
1470
|
+
```
|
|
1471
|
+
|
|
1472
|
+
# Debug Tools
|
|
1473
|
+
|
|
1474
|
+
## Parser Tracing
|
|
1475
|
+
|
|
1476
|
+
Enable tracing for debugging:
|
|
1477
|
+
|
|
1478
|
+
```rust
|
|
1479
|
+
let (result, trace) = parser.parse_with_trace();
|
|
1480
|
+
|
|
1481
|
+
// Print trace
|
|
1482
|
+
println!("{}", trace.format(&grammar));
|
|
1483
|
+
```
|
|
1484
|
+
|
|
1485
|
+
## Grammar Visualization
|
|
1486
|
+
|
|
1487
|
+
```rust
|
|
1488
|
+
use parsanol::portable::debug::GrammarVisualizer;
|
|
1489
|
+
|
|
1490
|
+
let viz = GrammarVisualizer::new(&grammar);
|
|
1491
|
+
|
|
1492
|
+
// Generate Mermaid diagram
|
|
1493
|
+
println!("{}", viz.to_mermaid());
|
|
1494
|
+
|
|
1495
|
+
// Generate GraphViz DOT
|
|
1496
|
+
println!("{}", viz.to_dot());
|
|
1497
|
+
```
|
|
1498
|
+
|
|
1499
|
+
# Performance
|
|
1500
|
+
|
|
1501
|
+
Parsanol-rs is designed for high performance:
|
|
1502
|
+
|
|
1503
|
+
- **18-44x Faster** than pure Ruby parsers (Parslet)
|
|
1504
|
+
|
|
1505
|
+
- **99.5% Fewer Allocations** through arena allocation
|
|
1506
|
+
|
|
1507
|
+
- **O(n) Parsing** via packrat memoization
|
|
1508
|
+
|
|
1509
|
+
- **SIMD Optimization**: Fast character matching via memchr
|
|
1510
|
+
|
|
1511
|
+
- **AHash**: Fast hashing for cache lookups
|
|
1512
|
+
|
|
1513
|
+
- **SmallVec**: Stack-allocated small collections
|
|
1514
|
+
|
|
1515
|
+
## Benchmarks
|
|
1516
|
+
|
|
1517
|
+
| Parser | Input Size | Time |
|
|
1518
|
+
|------------------------------|------------|--------|
|
|
1519
|
+
| parsanol-rs (Ruby Transform) | 1KB JSON | ~50µs |
|
|
1520
|
+
| parsanol-rs (Serialized) | 1KB JSON | ~30µs |
|
|
1521
|
+
| parsanol-rs (Native) | 1KB JSON | ~20µs |
|
|
1522
|
+
| Pure Ruby (Parslet) | 1KB JSON | ~800µs |
|
|
1523
|
+
|
|
1524
|
+
# Security
|
|
1525
|
+
|
|
1526
|
+
Parsanol-rs includes built-in protection against denial-of-service
|
|
1527
|
+
attacks.
|
|
1528
|
+
|
|
1529
|
+
## Default Limits
|
|
1530
|
+
|
|
1531
|
+
| Limit | Default Value | Description |
|
|
1532
|
+
|----|----|----|
|
|
1533
|
+
| `max_input_size` | 100 MB | Maximum input size in bytes |
|
|
1534
|
+
| `max_recursion_depth` | 1000 | Maximum recursion depth for nested structures |
|
|
1535
|
+
|
|
1536
|
+
## Custom Limits
|
|
1537
|
+
|
|
1538
|
+
For untrusted input, configure custom limits:
|
|
1539
|
+
|
|
1540
|
+
```rust
|
|
1541
|
+
use parsanol::portable::{PortableParser, AstArena, Grammar, ParseError};
|
|
1542
|
+
|
|
1543
|
+
// For untrusted input, use stricter limits
|
|
1544
|
+
let mut parser = PortableParser::with_limits(
|
|
1545
|
+
&grammar,
|
|
1546
|
+
input,
|
|
1547
|
+
&mut arena,
|
|
1548
|
+
10 * 1024 * 1024, // 10 MB max input
|
|
1549
|
+
100, // 100 max recursion depth
|
|
1550
|
+
);
|
|
1551
|
+
|
|
1552
|
+
match parser.parse() {
|
|
1553
|
+
Ok(ast) => { /* success */ },
|
|
1554
|
+
Err(ParseError::InputTooLarge { input_size, max_size }) => {
|
|
1555
|
+
eprintln!("Input too large: {} > {}", input_size, max_size);
|
|
1556
|
+
},
|
|
1557
|
+
Err(ParseError::RecursionLimitExceeded { depth, max_depth }) => {
|
|
1558
|
+
eprintln!("Recursion too deep: {} > {}", depth, max_depth);
|
|
1559
|
+
},
|
|
1560
|
+
Err(e) => { /* other errors */ },
|
|
1561
|
+
}
|
|
1562
|
+
```
|
|
1563
|
+
|
|
1564
|
+
## Best Practices
|
|
1565
|
+
|
|
1566
|
+
1. **Always limit input size** when parsing untrusted data
|
|
1567
|
+
2. **Use external timeouts** for network services (e.g., `tokio::time::timeout`)
|
|
1568
|
+
3. **Monitor memory usage** in production environments
|
|
1569
|
+
|
|
1570
|
+
See [SECURITY.md](SECURITY.md) for complete security documentation.
|
|
1571
|
+
|
|
1572
|
+
# Module Reference
|
|
1573
|
+
|
|
1574
|
+
## Core Modules
|
|
1575
|
+
|
|
1576
|
+
| Module | Description |
|
|
1577
|
+
|-------------------------------|---------------------------------------------|
|
|
1578
|
+
| `portable::parser` | PEG parsing engine with packrat memoization |
|
|
1579
|
+
| `portable::grammar` | Grammar representation and serialization |
|
|
1580
|
+
| `portable::ast` | AST node types |
|
|
1581
|
+
| `portable::arena` | Arena allocator for AST nodes |
|
|
1582
|
+
| `portable::cache` | Dense cache for memoization |
|
|
1583
|
+
| `portable::parser_dsl` | Fluent API for grammar definition |
|
|
1584
|
+
| `portable::transform` | Transform system for converting parse trees |
|
|
1585
|
+
| `portable::error` | Rich error reporting |
|
|
1586
|
+
| `portable::infix` | Infix expression parsing with precedence |
|
|
1587
|
+
| `portable::debug` | Debugging and visualization tools |
|
|
1588
|
+
| `portable::source_location` | Source span tracking with line/column info |
|
|
1589
|
+
| `portable::streaming` | Streaming parser support for large inputs |
|
|
1590
|
+
| `portable::streaming_builder` | Single-pass parsing with custom builders |
|
|
1591
|
+
| `portable::parallel` | Parallel parsing for batch processing |
|
|
1592
|
+
| `portable::incremental` | Incremental parsing for editor integration |
|
|
1593
|
+
| `portable::visitor` | AST visitor pattern implementation |
|
|
1594
|
+
| `portable::source_map` | Source map generation for debugging |
|
|
1595
|
+
|
|
1596
|
+
# Examples
|
|
1597
|
+
|
|
1598
|
+
See the `examples/` directory for 39 complete examples demonstrating
|
|
1599
|
+
real-world parsing scenarios:
|
|
1600
|
+
|
|
1601
|
+
## Expression Parsers
|
|
1602
|
+
|
|
1603
|
+
| Example | Description |
|
|
1604
|
+
|----|----|
|
|
1605
|
+
| `calculator-pattern` | Parse expressions with pattern-based transforms |
|
|
1606
|
+
| `calculator-transform` | Parse and evaluate expressions with native transforms |
|
|
1607
|
+
| `boolean-algebra` | Parse boolean expressions with AND, OR, NOT operators |
|
|
1608
|
+
| `expression-evaluator` | Evaluate expressions with variables and function calls |
|
|
1609
|
+
| `prec-calc` | Precedence climbing algorithm for infix expressions |
|
|
1610
|
+
|
|
1611
|
+
## Data Formats
|
|
1612
|
+
|
|
1613
|
+
| Example | Description |
|
|
1614
|
+
|--------------|------------------------------------------------------------|
|
|
1615
|
+
| `json-pattern` | JSON parser with pattern matching |
|
|
1616
|
+
| `json-transform` | JSON parser with native transforms |
|
|
1617
|
+
| `csv-pattern` | CSV parser handling quoted fields (pattern mode) |
|
|
1618
|
+
| `csv-transform` | CSV parser handling quoted fields (transform mode) |
|
|
1619
|
+
| `ini` | INI configuration file parser |
|
|
1620
|
+
| `simple-xml` | XML parser with tag matching |
|
|
1621
|
+
| `markup` | Lightweight markup language parser |
|
|
1622
|
+
| `toml` | TOML configuration file parser |
|
|
1623
|
+
| `yaml` | YAML subset parser |
|
|
1624
|
+
| `markdown` | Markdown subset parser with headers and lists |
|
|
1625
|
+
| `iso-8601` | ISO 8601 date/time/duration parser |
|
|
1626
|
+
| `iso-6709` | ISO 6709 geographic coordinate parser |
|
|
1627
|
+
|
|
1628
|
+
## URLs & Network
|
|
1629
|
+
|
|
1630
|
+
| Example | Description |
|
|
1631
|
+
|--------------|-----------------------------------------------|
|
|
1632
|
+
| `url` | URL parser with scheme, host, path components |
|
|
1633
|
+
| `email` | Email address parser with validation |
|
|
1634
|
+
| `ip-address` | IPv4/IPv6 address parser with validation |
|
|
1635
|
+
|
|
1636
|
+
## Code & Templates
|
|
1637
|
+
|
|
1638
|
+
| Example | Description |
|
|
1639
|
+
|------------|--------------------------------------------------|
|
|
1640
|
+
| `erb` | ERB template parser for Ruby templates |
|
|
1641
|
+
| `sexp` | S-expression parser for Lisp-style syntax |
|
|
1642
|
+
| `minilisp` | MiniLisp parser demonstrating recursive grammars |
|
|
1643
|
+
|
|
1644
|
+
## Text Processing
|
|
1645
|
+
|
|
1646
|
+
| Example | Description |
|
|
1647
|
+
|-------------------|---------------------------------------------|
|
|
1648
|
+
| `balanced-parens` | Balanced parentheses parser |
|
|
1649
|
+
| `string-literal` | String literal parser with escape sequences |
|
|
1650
|
+
| `sentence` | Sentence parser with Unicode support |
|
|
1651
|
+
| `comments` | Comment parser (line and block comments) |
|
|
1652
|
+
|
|
1653
|
+
## Error Handling
|
|
1654
|
+
|
|
1655
|
+
| Example | Description |
|
|
1656
|
+
|-------------------|------------------------------------------|
|
|
1657
|
+
| `error-reporting` | Rich error reporting with tree structure |
|
|
1658
|
+
| `error-recovery` | Error recovery strategies |
|
|
1659
|
+
| `deepest-errors` | Deepest error point tracking |
|
|
1660
|
+
| `nested-errors` | Nested error tree visualization |
|
|
1661
|
+
|
|
1662
|
+
## Advanced Features
|
|
1663
|
+
|
|
1664
|
+
| Example | Description |
|
|
1665
|
+
|-----------------|----------------------------------------|
|
|
1666
|
+
| `streaming` | Streaming parser for large inputs |
|
|
1667
|
+
| `incremental` | Incremental parsing for editor integration |
|
|
1668
|
+
| `linter` | Code linter with custom validation |
|
|
1669
|
+
| `custom-atoms` | Custom atom registration |
|
|
1670
|
+
| `modularity` | Grammar composition from modules |
|
|
1671
|
+
|
|
1672
|
+
Run examples with:
|
|
1673
|
+
|
|
1674
|
+
```bash
|
|
1675
|
+
cargo run --example calculator-transform
|
|
1676
|
+
cargo run --example json-pattern
|
|
1677
|
+
cargo run --example url
|
|
1678
|
+
```
|
|
1679
|
+
|
|
1680
|
+
Full documentation and interactive examples available at [the
|
|
1681
|
+
website](https://parsanol.github.io/examples).
|
|
1682
|
+
|
|
1683
|
+
# API Stability
|
|
1684
|
+
|
|
1685
|
+
The API is currently in active development. Version 0.x indicates that
|
|
1686
|
+
breaking changes may occur.
|
|
1687
|
+
|
|
1688
|
+
Stable APIs:
|
|
1689
|
+
|
|
1690
|
+
- `Grammar` and `GrammarBuilder`
|
|
1691
|
+
|
|
1692
|
+
- `PortableParser` basic parsing
|
|
1693
|
+
|
|
1694
|
+
- `AstArena` and `AstNode`
|
|
1695
|
+
|
|
1696
|
+
- Parser DSL combinators
|
|
1697
|
+
|
|
1698
|
+
- Streaming builder trait and built-in builders
|
|
1699
|
+
|
|
1700
|
+
- Parallel parsing functions
|
|
1701
|
+
|
|
1702
|
+
Experimental APIs (may change):
|
|
1703
|
+
|
|
1704
|
+
- `Transform` and pattern matching
|
|
1705
|
+
|
|
1706
|
+
- Rich error reporting
|
|
1707
|
+
|
|
1708
|
+
- Infix expression parsing
|
|
1709
|
+
|
|
1710
|
+
- Debug/trace tools
|
|
1711
|
+
|
|
1712
|
+
# Documentation
|
|
1713
|
+
|
|
1714
|
+
## Architecture
|
|
1715
|
+
|
|
1716
|
+
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the overall system architecture.
|
|
1717
|
+
|
|
1718
|
+
## Development
|
|
1719
|
+
|
|
1720
|
+
- [docs/refactoring-plan.md](docs/refactoring-plan.md) - Current refactoring roadmap
|
|
1721
|
+
- [docs/continuation-prompt.md](docs/continuation-prompt.md) - Prompt for continuing work
|
|
1722
|
+
- [docs/MIGRATION.md](docs/MIGRATION.md) - Migration guide from Parslet
|
|
1723
|
+
|
|
1724
|
+
# License
|
|
1725
|
+
|
|
1726
|
+
MIT License - see [LICENSE](LICENSE) file for details.
|
|
1727
|
+
|
|
1728
|
+
# Contributing
|
|
1729
|
+
|
|
1730
|
+
Contributions are welcome! Please feel free to submit issues and pull
|
|
1731
|
+
requests at [GitHub](https://github.com/parsanol/parsanol-rs).
|
|
1732
|
+
|
|
1733
|
+
## Development Setup
|
|
1734
|
+
|
|
1735
|
+
```bash
|
|
1736
|
+
# Clone the repository
|
|
1737
|
+
git clone https://github.com/parsanol/parsanol-rs.git
|
|
1738
|
+
cd parsanol-rs
|
|
1739
|
+
|
|
1740
|
+
# Build (workspace)
|
|
1741
|
+
cargo build
|
|
1742
|
+
|
|
1743
|
+
# Run tests (234 unit tests)
|
|
1744
|
+
cargo test --lib
|
|
1745
|
+
|
|
1746
|
+
# Run all examples
|
|
1747
|
+
cargo build --examples
|
|
1748
|
+
|
|
1749
|
+
# Run benchmarks
|
|
1750
|
+
cargo bench
|
|
1751
|
+
|
|
1752
|
+
# Check code quality
|
|
1753
|
+
cargo clippy --lib -- -D warnings
|
|
1754
|
+
cargo fmt --check
|
|
1755
|
+
```
|
|
1756
|
+
|
|
1757
|
+
## Testing
|
|
1758
|
+
|
|
1759
|
+
The test suite consists of multiple types of tests:
|
|
1760
|
+
|
|
1761
|
+
**Unit tests:** 234 tests covering internal functionality of each module
|
|
1762
|
+
(parser, arena, cache, transform, derive, etc.).
|
|
1763
|
+
|
|
1764
|
+
**Integration tests:** Located in `tests/` directory, test end-to-end parsing scenarios.
|
|
1765
|
+
|
|
1766
|
+
**Examples:** 39 runnable parsers in `examples/` directory demonstrating real-world usage.
|
|
1767
|
+
Examples are compiled and tested via `cargo build --examples`.
|
|
1768
|
+
|
|
1769
|
+
**Documentation tests (doc tests):** Code examples in documentation comments. Note that many doc tests are marked with `ignore` because they show **incomplete code snippets** (e.g., method signatures or pseudocode) rather than complete runnable examples. This is intentional - the doc tests illustrate API patterns, while the `examples/` directory contains fully runnable code that is verified by CI.
|
|
1770
|
+
|
|
1771
|
+
To run all tests:
|
|
1772
|
+
|
|
1773
|
+
```bash
|
|
1774
|
+
# Unit + integration tests
|
|
1775
|
+
cargo test
|
|
1776
|
+
|
|
1777
|
+
# Include doc tests (most will be ignored as designed)
|
|
1778
|
+
cargo test --doc
|
|
1779
|
+
|
|
1780
|
+
# Test examples compile
|
|
1781
|
+
cargo build --examples
|
|
1782
|
+
|
|
1783
|
+
# Run ignored doc tests (will fail if not complete)
|
|
1784
|
+
cargo test -- --ignored
|
|
1785
|
+
```
|
|
1786
|
+
|
|
1787
|
+
# Release Process
|
|
1788
|
+
|
|
1789
|
+
This project uses [release-plz](https://release-plz.dev/) for automated releases.
|
|
1790
|
+
|
|
1791
|
+
## How It Works
|
|
1792
|
+
|
|
1793
|
+
1. **Push to main** → release-plz creates/updates a Release PR
|
|
1794
|
+
|
|
1795
|
+
2. **Review and merge the Release PR** → Version is updated in main
|
|
1796
|
+
|
|
1797
|
+
3. **After merge** → release-plz automatically:
|
|
1798
|
+
- Creates a git tag (e.g., `v0.1.2`)
|
|
1799
|
+
- Publishes to crates.io
|
|
1800
|
+
- Creates a GitHub release
|
|
1801
|
+
|
|
1802
|
+
4. **Build artifacts** → CI builds native libraries and uploads them to the GitHub release
|
|
1803
|
+
|
|
1804
|
+
## Maintainer Workflow
|
|
1805
|
+
|
|
1806
|
+
### Normal Release (Recommended)
|
|
1807
|
+
|
|
1808
|
+
Just push commits with conventional commit messages:
|
|
1809
|
+
|
|
1810
|
+
```bash
|
|
1811
|
+
git commit -m "feat: add new parser combinator"
|
|
1812
|
+
git push origin main
|
|
1813
|
+
```
|
|
1814
|
+
|
|
1815
|
+
release-plz will:
|
|
1816
|
+
1. Create a Release PR with version bump (e.g., `0.1.1` → `0.1.2` for `feat:`)
|
|
1817
|
+
2. Wait for you to review and merge
|
|
1818
|
+
3. Publish automatically after merge
|
|
1819
|
+
|
|
1820
|
+
### Manual Release
|
|
1821
|
+
|
|
1822
|
+
If you need to trigger a release manually:
|
|
1823
|
+
|
|
1824
|
+
1. Go to **Actions** → **Release** workflow
|
|
1825
|
+
2. Click **Run workflow**
|
|
1826
|
+
3. Select action:
|
|
1827
|
+
- `auto` (default): Let release-plz decide
|
|
1828
|
+
- `release-pr`: Just create/update the Release PR
|
|
1829
|
+
- `release`: Force a release immediately
|
|
1830
|
+
|
|
1831
|
+
### Version Bump Rules
|
|
1832
|
+
|
|
1833
|
+
release-plz uses [conventional commits](https://www.conventionalcommits.org/):
|
|
1834
|
+
|
|
1835
|
+
| Commit Type | Version Bump |
|
|
1836
|
+
|-------------|--------------|
|
|
1837
|
+
| `feat:` | Minor (0.1.0 → 0.2.0) |
|
|
1838
|
+
| `fix:` | Patch (0.1.0 → 0.1.1) |
|
|
1839
|
+
| `feat!:` or `fix!:` | Major (0.1.0 → 1.0.0) |
|
|
1840
|
+
| `docs:`, `chore:`, etc. | No bump (changelog only) |
|
|
1841
|
+
|
|
1842
|
+
### What Gets Released
|
|
1843
|
+
|
|
1844
|
+
- **crates.io**: `parsanol` crate
|
|
1845
|
+
- **GitHub Release**: With release notes
|
|
1846
|
+
- **Build Artifacts**: Native libraries for Linux, macOS, Windows (x64, ARM64)
|
|
1847
|
+
|
|
1848
|
+
### Troubleshooting
|
|
1849
|
+
|
|
1850
|
+
**"Already published" error:**
|
|
1851
|
+
- release-plz sees an existing tag and thinks the version is already published
|
|
1852
|
+
- Solution: Ensure Cargo.toml version matches what you want to publish
|
|
1853
|
+
|
|
1854
|
+
**No Release PR created:**
|
|
1855
|
+
- Check that commits follow conventional commit format
|
|
1856
|
+
- Check GitHub Actions logs for the `release-pr` job
|
|
1857
|
+
|
|
1858
|
+
**Publish failed:**
|
|
1859
|
+
- Check crates.io API token is valid
|
|
1860
|
+
- Check version doesn't already exist on crates.io
|
|
1861
|
+
|
|
1862
|
+
## FFI Feature Testing
|
|
1863
|
+
|
|
1864
|
+
This crate supports optional Ruby and WebAssembly (WASM) FFI features.
|
|
1865
|
+
These must be tested explicitly.
|
|
1866
|
+
|
|
1867
|
+
> [!IMPORTANT]
|
|
1868
|
+
> FFI features require additional setup and may not compile/link in all
|
|
1869
|
+
> environments. Always verify FFI code compiles before pushing to CI.
|
|
1870
|
+
|
|
1871
|
+
### Ruby FFI Testing
|
|
1872
|
+
|
|
1873
|
+
The Ruby FFI uses the `magnus` crate to provide Ruby bindings.
|
|
1874
|
+
|
|
1875
|
+
**Prerequisites:**
|
|
1876
|
+
- Ruby 3.0+ installed
|
|
1877
|
+
- Ruby development headers (macOS: `brew install ruby`, Ubuntu: `sudo apt-get install ruby-dev`)
|
|
1878
|
+
|
|
1879
|
+
**Testing:**
|
|
1880
|
+
```bash
|
|
1881
|
+
# Compile-time check (no Ruby required for linking)
|
|
1882
|
+
cargo check --features ruby
|
|
1883
|
+
cargo clippy --features ruby --lib -- -D warnings
|
|
1884
|
+
|
|
1885
|
+
# Full integration tests (requires Ruby runtime)
|
|
1886
|
+
# Note: These tests are marked #[ignore] - run manually
|
|
1887
|
+
cargo test --features ruby --test ruby_ffi -- --ignored
|
|
1888
|
+
```
|
|
1889
|
+
|
|
1890
|
+
**Test coverage:**
|
|
1891
|
+
- `tests/ruby_ffi.rs` - Comprehensive tests for RubyBuilder, RubyObject trait
|
|
1892
|
+
- Magnus type annotations (e.g., `funcall::<&str, (), Value>`)
|
|
1893
|
+
- Error handling from Ruby callbacks
|
|
1894
|
+
- Parse result conversion
|
|
1895
|
+
|
|
1896
|
+
### WebAssembly FFI Testing
|
|
1897
|
+
|
|
1898
|
+
The WASM FFI uses `wasm-bindgen` for JavaScript bindings.
|
|
1899
|
+
|
|
1900
|
+
**Prerequisites:**
|
|
1901
|
+
- `wasm-pack` installed (`cargo install wasm-pack`)
|
|
1902
|
+
- `.cargo/config.toml` configures WASM-specific rustflags (auto-applied)
|
|
1903
|
+
|
|
1904
|
+
**Testing:**
|
|
1905
|
+
```bash
|
|
1906
|
+
# Compile-time check
|
|
1907
|
+
cargo check --features wasm
|
|
1908
|
+
cargo clippy --features wasm --lib -- -D warnings
|
|
1909
|
+
|
|
1910
|
+
# Build for WASM target
|
|
1911
|
+
cargo build --features wasm --target wasm32-unknown-unknown
|
|
1912
|
+
|
|
1913
|
+
# Full WASM build and test
|
|
1914
|
+
wasm-pack build --features wasm
|
|
1915
|
+
wasm-pack test --node --features wasm
|
|
1916
|
+
```
|
|
1917
|
+
|
|
1918
|
+
**Test coverage:**
|
|
1919
|
+
- `tests/wasm_ffi.rs` - Tests for WASM exports, grammar serialization
|
|
1920
|
+
- JsValue conversions
|
|
1921
|
+
- Error handling for WASM
|
|
1922
|
+
|
|
1923
|
+
### CI Integration
|
|
1924
|
+
|
|
1925
|
+
CI automatically tests FFI features:
|
|
1926
|
+
|
|
1927
|
+
``` yaml
|
|
1928
|
+
# From .github/workflows/ci.yml
|
|
1929
|
+
strategy:
|
|
1930
|
+
matrix:
|
|
1931
|
+
feature: ["", "logging", "ruby", "wasm"]
|
|
1932
|
+
```
|
|
1933
|
+
|
|
1934
|
+
The Ruby and WASM feature tests run on every push to catch FFI
|
|
1935
|
+
regressions early.
|
|
1936
|
+
|
|
1937
|
+
# Release Process
|
|
1938
|
+
|
|
1939
|
+
This project uses [release-plz](https://release-plz.dev/) for automated releases.
|
|
1940
|
+
|
|
1941
|
+
## How It Works
|
|
1942
|
+
|
|
1943
|
+
```
|
|
1944
|
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
1945
|
+
│ RELEASE-PLZ WORKFLOW │
|
|
1946
|
+
└─────────────────────────────────────────────────────────────────────────────┘
|
|
1947
|
+
|
|
1948
|
+
Push to main
|
|
1949
|
+
│
|
|
1950
|
+
▼
|
|
1951
|
+
┌─────────────────┐
|
|
1952
|
+
│ release-pr job │ Creates/updates Release PR
|
|
1953
|
+
└────────┬────────┘
|
|
1954
|
+
│
|
|
1955
|
+
▼
|
|
1956
|
+
┌─────────────────┐
|
|
1957
|
+
│ Release PR │ Contains version bump + changelog
|
|
1958
|
+
│ (on GitHub) │
|
|
1959
|
+
└────────┬────────┘
|
|
1960
|
+
│
|
|
1961
|
+
│ Maintainer reviews and merges
|
|
1962
|
+
▼
|
|
1963
|
+
┌─────────────────┐
|
|
1964
|
+
│ release job │ Runs release-plz release
|
|
1965
|
+
└────────┬────────┘
|
|
1966
|
+
│
|
|
1967
|
+
├──────────────────────────────┐
|
|
1968
|
+
│ │
|
|
1969
|
+
▼ ▼
|
|
1970
|
+
┌─────────────────┐ ┌─────────────────┐
|
|
1971
|
+
│ Create tag │ │ Publish to │
|
|
1972
|
+
│ (v0.1.2) │ │ crates.io │
|
|
1973
|
+
└────────┬────────┘ └─────────────────┘
|
|
1974
|
+
│
|
|
1975
|
+
▼
|
|
1976
|
+
┌─────────────────┐
|
|
1977
|
+
│ GitHub Release │ With release notes
|
|
1978
|
+
└────────┬────────┘
|
|
1979
|
+
│
|
|
1980
|
+
▼
|
|
1981
|
+
┌─────────────────┐
|
|
1982
|
+
│ Build jobs │ Build native libraries
|
|
1983
|
+
└────────┬────────┘
|
|
1984
|
+
│
|
|
1985
|
+
▼
|
|
1986
|
+
┌─────────────────┐
|
|
1987
|
+
│ Update Release │ Upload artifacts
|
|
1988
|
+
└─────────────────┘
|
|
1989
|
+
```
|
|
1990
|
+
|
|
1991
|
+
## Maintainer Workflow
|
|
1992
|
+
|
|
1993
|
+
### Normal Release (Recommended)
|
|
1994
|
+
|
|
1995
|
+
Just push commits with conventional commit messages:
|
|
1996
|
+
|
|
1997
|
+
```bash
|
|
1998
|
+
git commit -m "feat: add new parser combinator"
|
|
1999
|
+
git push origin main
|
|
2000
|
+
```
|
|
2001
|
+
|
|
2002
|
+
release-plz will:
|
|
2003
|
+
1. Create a Release PR with version bump (e.g., `0.1.1` → `0.1.2` for `feat:`)
|
|
2004
|
+
2. Wait for you to review and merge
|
|
2005
|
+
3. Publish automatically after merge
|
|
2006
|
+
|
|
2007
|
+
### Manual Release
|
|
2008
|
+
|
|
2009
|
+
If you need to trigger a release manually:
|
|
2010
|
+
|
|
2011
|
+
1. Go to **Actions** → **Release** workflow
|
|
2012
|
+
2. Click **Run workflow**
|
|
2013
|
+
3. Select action:
|
|
2014
|
+
- `auto` (default): Let release-plz decide
|
|
2015
|
+
- `release-pr`: Just create/update the Release PR
|
|
2016
|
+
- `release`: Force a release immediately
|
|
2017
|
+
|
|
2018
|
+
### Version Bump Rules
|
|
2019
|
+
|
|
2020
|
+
release-plz uses [conventional commits](https://www.conventionalcommits.org/):
|
|
2021
|
+
|
|
2022
|
+
| Commit Type | Version Bump |
|
|
2023
|
+
|-------------|--------------|
|
|
2024
|
+
| `feat:` | Minor (0.1.0 → 0.2.0) |
|
|
2025
|
+
| `fix:` | Patch (0.1.0 → 0.1.1) |
|
|
2026
|
+
| `feat!:` or `fix!:` | Major (0.1.0 → 1.0.0) |
|
|
2027
|
+
| `docs:`, `chore:`, etc. | No bump (changelog only) |
|
|
2028
|
+
|
|
2029
|
+
### What Gets Released
|
|
2030
|
+
|
|
2031
|
+
- **crates.io**: `parsanol` crate
|
|
2032
|
+
- **GitHub Release**: With release notes
|
|
2033
|
+
- **Build Artifacts**: Native libraries for Linux, macOS, Windows (x64, ARM64)
|
|
2034
|
+
|
|
2035
|
+
### Troubleshooting
|
|
2036
|
+
|
|
2037
|
+
**"Already published" error:**
|
|
2038
|
+
- release-plz sees an existing tag and thinks the version is already published
|
|
2039
|
+
- Solution: Ensure Cargo.toml version matches what you want to publish
|
|
2040
|
+
|
|
2041
|
+
**No Release PR created:**
|
|
2042
|
+
- Check that commits follow conventional commit format
|
|
2043
|
+
- Check GitHub Actions logs for the `release-pr` job
|
|
2044
|
+
|
|
2045
|
+
**Publish failed:**
|
|
2046
|
+
- Check that the `crates.io` environment is configured in repository settings
|
|
2047
|
+
- Check that trusted publishing is enabled
|
|
2048
|
+
|
|
2049
|
+
# See Also
|
|
2050
|
+
|
|
2051
|
+
- [parsanol-ruby](https://github.com/parsanol/parsanol-ruby) - Ruby
|
|
2052
|
+
bindings
|
|
2053
|
+
|
|
2054
|
+
- [Documentation
|
|
2055
|
+
Website](https://github.com/parsanol/parsanol.github.io)
|
|
2056
|
+
|
|
2057
|
+
- [Parslet](https://github.com/kschiess/parslet) - Original Ruby PEG
|
|
2058
|
+
parser (inspiration)
|