birman 0.1.0 → 0.2.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +22 -6
- data/lib/birman/combinators.rb +42 -15
- data/lib/birman/matchers.rb +38 -9
- data/lib/birman/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6e56dfecb27c1584f8aefdb873503084e140abf83d53f2b85ed453ad1e25dbbe
|
|
4
|
+
data.tar.gz: 35b4ee8725298c3809fbde953f51fca4c550ac207a68a2eade43ad7745e02b15
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: abf8ca7031fe77792fb4a3e0a2c8480358320b2edfeebe0e8da7de0814cf71cbccc5229cf8fb710ec1ddebd49208a9b6f7cf8d9a3b6a066830b57b7271e90827
|
|
7
|
+
data.tar.gz: bdf4b37b3adcfc42ce7f5eb1bee447c83bbe5cc25ab4795a6478991775f9d913554426abf5c6b1392314f072fced9e7f53b8615ffb4fe5eeb37d0483764ece40
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.1] - 2026-10-17
|
|
4
|
+
|
|
5
|
+
- **Fixed:** `rep` and `maybe` over something that can match empty.
|
|
6
|
+
An empty first match now counts, so `match(/a*/).rep(1)` accepts `""`
|
|
7
|
+
and `match(/a*/).maybe` gives `""` instead of `nil`. After real
|
|
8
|
+
matches, an empty one still just ends the repetition. (A plain
|
|
9
|
+
`.rep` over such a matcher now gives `[""]` on empty input, not `[]`.)
|
|
10
|
+
- **Fixed:** a `label` now replaces the low-level expectations when its
|
|
11
|
+
matcher succeeds without consuming anything, as it already did when
|
|
12
|
+
the matcher failed: `str("a").maybe.label("prefix") >> str("b")` on
|
|
13
|
+
`"x"` reports `expected prefix or "b"`.
|
|
14
|
+
|
|
15
|
+
## [0.2.0] - 2026-10-17
|
|
16
|
+
|
|
17
|
+
- **Added:** `one_of` takes a hash mapping literals to the values they
|
|
18
|
+
produce: `one_of("true" => true, "false" => false, "null" => nil)`.
|
|
19
|
+
- **Added:** `only(x)` inside `seq` makes that part the sequence's
|
|
20
|
+
whole value; the other parts must still match but are dropped:
|
|
21
|
+
`seq("[", commit, only(sep_by(value, ",")), "]")`. Its `.as` name, if
|
|
22
|
+
any, passes outward. Using it twice, mixing it with other `.as`
|
|
23
|
+
names, or using it outside `seq` raises `ArgumentError`.
|
|
24
|
+
- **Changed:** a bare string is a literal everywhere a matcher goes, not
|
|
25
|
+
just inside `seq`, `either` and the other helpers. A string used as a
|
|
26
|
+
rule body (`rule(:semi) { ";" }`) or after `>>`, `|`, `&` or `>` now
|
|
27
|
+
skips trailing spacing and, if word-like, is a keyword. Before, it
|
|
28
|
+
matched exactly those characters, so `seq(semi, "x")` failed on
|
|
29
|
+
`"; x"`. Use `str("...")` for the old exact match.
|
|
30
|
+
|
|
3
31
|
## [0.1.0] - 2026-10-17
|
|
4
32
|
|
|
5
33
|
First release: a small PEG parser library for Ruby 3.2+.
|
data/README.md
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
# Birman
|
|
6
6
|
|
|
7
|
+
[](https://rubygems.org/gems/birman)
|
|
8
|
+
[](https://github.com/phaedryx/birman/actions/workflows/main.yml)
|
|
9
|
+
|
|
7
10
|
A small PEG parser library for Ruby 3.2+: module-based grammars,
|
|
8
11
|
immutable `Data` matchers and AST nodes with source spans, packrat
|
|
9
12
|
memoization, commit points, labels, and `Success`/`Failure` results.
|
|
@@ -14,6 +17,12 @@ memoization, commit points, labels, and `Success`/`Failure` results.
|
|
|
14
17
|
bundle add birman
|
|
15
18
|
```
|
|
16
19
|
|
|
20
|
+
or in your Gemfile:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
gem "birman", "~> 0.2.1"
|
|
24
|
+
```
|
|
25
|
+
|
|
17
26
|
```ruby
|
|
18
27
|
require "birman"
|
|
19
28
|
|
|
@@ -68,7 +77,8 @@ end
|
|
|
68
77
|
```
|
|
69
78
|
|
|
70
79
|
See `examples/calc.rb` for a complete small language with an
|
|
71
|
-
interpreter,
|
|
80
|
+
interpreter, `examples/json.rb` for a full JSON parser (tested against
|
|
81
|
+
`JSON.parse`), and `Grammar.to_ebnf` to print any grammar.
|
|
72
82
|
|
|
73
83
|
## Building blocks
|
|
74
84
|
|
|
@@ -76,7 +86,9 @@ interpreter, and `Grammar.to_ebnf` to print any grammar.
|
|
|
76
86
|
|-------------------------------|------------------------------------------------------|
|
|
77
87
|
| `seq(a, b, c)` | sequence |
|
|
78
88
|
| `seq(a, commit, b, c)` | after `a`, the rest must match or parsing stops here |
|
|
89
|
+
| `seq("[", only(x), "]")` | value is just `x`; the rest must match but is dropped |
|
|
79
90
|
| `either(a, b, c)` | ordered choice |
|
|
91
|
+
| `one_of("true" => true, ...)` | one of these literals, producing its value (tried in order) |
|
|
80
92
|
| `x.as(:key)` | name a part; the sequence's value becomes a hash |
|
|
81
93
|
| `x.to(Node)` | build a node from that hash (or from a single value) |
|
|
82
94
|
| `infix(operand, "+", "-", node: BinOp)` | left-associative binary operators; `BinOp` needs `op`, `left`, `right` fields |
|
|
@@ -97,9 +109,11 @@ first: `>>`, `&`, `|`, `>`.
|
|
|
97
109
|
Declare `ignore :spacing` and whitespace (and comments, with
|
|
98
110
|
`Rules::Comments`) takes care of itself:
|
|
99
111
|
|
|
100
|
-
-
|
|
101
|
-
|
|
102
|
-
|
|
112
|
+
- Bare strings are **literals** wherever a matcher goes: in `seq`,
|
|
113
|
+
`either`, `parens` and friends, as a rule body (`rule(:semi) { ";" }`),
|
|
114
|
+
or after `>>`, `|`, `&` and `>`. They skip trailing spacing, and
|
|
115
|
+
word-like ones (`"let"`) are keywords, so `"let"` won't match the
|
|
116
|
+
start of `letter`.
|
|
103
117
|
- `str("...")` and `match(/.../)` are **raw**: exactly those characters,
|
|
104
118
|
no skipping.
|
|
105
119
|
- Rules for tokens (numbers, names, strings) should be declared with
|
|
@@ -175,9 +189,11 @@ has one.
|
|
|
175
189
|
## Development
|
|
176
190
|
|
|
177
191
|
```sh
|
|
178
|
-
bin/setup
|
|
179
|
-
bundle exec rake
|
|
192
|
+
bin/setup # install dependencies
|
|
193
|
+
bundle exec rake # tests + rubocop
|
|
180
194
|
ruby examples/calc.rb
|
|
195
|
+
ruby examples/json.rb
|
|
196
|
+
ruby bench/json.rb # vs JSON.parse, Parslet, Treetop, Citrus (installs them)
|
|
181
197
|
```
|
|
182
198
|
|
|
183
199
|
## License
|
data/lib/birman/combinators.rb
CHANGED
|
@@ -4,14 +4,17 @@ module Birman
|
|
|
4
4
|
# Marker for seq: everything after it is committed (see Matcher#>).
|
|
5
5
|
COMMIT = Object.new.tap { |o| def o.inspect = "Birman::COMMIT" }.freeze
|
|
6
6
|
|
|
7
|
+
# Marks the part of a seq that becomes its whole value (see #only).
|
|
8
|
+
Only = Data.define(:matcher)
|
|
9
|
+
|
|
7
10
|
# Building blocks and parameterized rule factories, available in
|
|
8
11
|
# every grammar. They're plain methods, so a module can override
|
|
9
12
|
# them or add more.
|
|
10
13
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# the grammar declares `ignore`, and word-like ones are keywords.
|
|
14
|
-
# str and match for exact, raw matching.
|
|
14
|
+
# A bare string anywhere a matcher is expected (in seq or either, as
|
|
15
|
+
# a rule body, after >> or |) is a literal: it skips trailing spacing
|
|
16
|
+
# when the grammar declares `ignore`, and word-like ones are keywords.
|
|
17
|
+
# Use str and match for exact, raw matching.
|
|
15
18
|
module Combinators
|
|
16
19
|
# --- Raw matchers ---
|
|
17
20
|
|
|
@@ -26,14 +29,18 @@ module Birman
|
|
|
26
29
|
|
|
27
30
|
# --- Literals and the readable forms ---
|
|
28
31
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
# Anything usable as a matcher: strings become literals, regexps
|
|
33
|
+
# raw patterns, and matchers pass through unchanged.
|
|
34
|
+
def lit(x) = Birman.coerce(x)
|
|
35
|
+
|
|
36
|
+
# The matcher a literal stands for: "let" is a keyword, "=" an exact
|
|
37
|
+
# string, either way followed by the grammar's ignore rule. Built
|
|
38
|
+
# once per grammar instance.
|
|
39
|
+
def literal_matcher(text)
|
|
40
|
+
(@literal_matchers ||= {})[text] ||= begin
|
|
41
|
+
base = text.match?(/\A\w+\z/) ? keyword(text) : str(text)
|
|
42
|
+
Lexeme.new(base, ignore_rule, text)
|
|
43
|
+
end
|
|
37
44
|
end
|
|
38
45
|
|
|
39
46
|
# Sequence. Put `commit` where the construct becomes unambiguous:
|
|
@@ -42,16 +49,29 @@ module Birman
|
|
|
42
49
|
commit_at = items.index(COMMIT)
|
|
43
50
|
raise ArgumentError, "commit can't come first in a seq: nothing has matched yet" if commit_at&.zero?
|
|
44
51
|
|
|
45
|
-
parts = items.reject { _1.equal?(COMMIT) }
|
|
46
|
-
|
|
52
|
+
parts = items.reject { _1.equal?(COMMIT) }
|
|
53
|
+
only_at = only_index(parts)
|
|
54
|
+
Seq.new(parts.map { _1.is_a?(Only) ? _1.matcher : lit(_1) }, commit_at, only_at)
|
|
47
55
|
end
|
|
48
56
|
|
|
49
57
|
def commit = COMMIT
|
|
50
58
|
|
|
59
|
+
# Inside seq, makes this part the seq's whole value; the rest still
|
|
60
|
+
# has to match but is dropped:
|
|
61
|
+
# seq("[", commit, only(sep_by(value, ",")), "]") # => [...]
|
|
62
|
+
def only(x) = Only.new(lit(x))
|
|
63
|
+
|
|
51
64
|
# Ordered choice: the first alternative that matches wins.
|
|
52
65
|
def either(*alts) = alts.size == 1 ? lit(alts.first) : Choice.new(alts.map { lit(_1) })
|
|
53
66
|
|
|
54
|
-
|
|
67
|
+
# One of several literals. Given a hash, each literal produces its
|
|
68
|
+
# value: one_of("true" => true, "null" => nil). They're tried in
|
|
69
|
+
# order, so put longer ones first ("<=" before "<").
|
|
70
|
+
def one_of(*choices)
|
|
71
|
+
return either(*choices) unless choices in [Hash => table]
|
|
72
|
+
|
|
73
|
+
either(*table.map { |text, value| lit(text).map { value } })
|
|
74
|
+
end
|
|
55
75
|
|
|
56
76
|
# Left-associative binary operators, folded into nodes:
|
|
57
77
|
# infix(product, "+", "-", node: BinOp) # BinOp = node(:op, :left, :right)
|
|
@@ -70,6 +90,13 @@ module Birman
|
|
|
70
90
|
|
|
71
91
|
private
|
|
72
92
|
|
|
93
|
+
def only_index(parts)
|
|
94
|
+
indexes = parts.each_index.select { parts[_1].is_a?(Only) }
|
|
95
|
+
raise ArgumentError, "a seq can have only one only(...)" if indexes.size > 1
|
|
96
|
+
|
|
97
|
+
indexes.first
|
|
98
|
+
end
|
|
99
|
+
|
|
73
100
|
def ignore_rule = self.class.respond_to?(:ignore) ? self.class.ignore : nil
|
|
74
101
|
end
|
|
75
102
|
end
|
data/lib/birman/matchers.rb
CHANGED
|
@@ -4,8 +4,9 @@ module Birman
|
|
|
4
4
|
def self.coerce(x)
|
|
5
5
|
case x
|
|
6
6
|
when Matcher then x
|
|
7
|
-
when String then
|
|
7
|
+
when String then Lit.new(x)
|
|
8
8
|
when Regexp then Pat.new(x)
|
|
9
|
+
when Only then raise ArgumentError, "only(...) works inside seq, to make one part the seq's value"
|
|
9
10
|
else raise ArgumentError, "can't make a matcher from #{x.inspect}"
|
|
10
11
|
end
|
|
11
12
|
end
|
|
@@ -63,10 +64,20 @@ module Birman
|
|
|
63
64
|
|
|
64
65
|
private
|
|
65
66
|
|
|
66
|
-
def uncommitted_parts(m) = m.is_a?(Seq) && m.commit_at.nil? ? m.parts : [m]
|
|
67
|
+
def uncommitted_parts(m) = m.is_a?(Seq) && m.commit_at.nil? && m.only_at.nil? ? m.parts : [m]
|
|
67
68
|
end
|
|
68
69
|
|
|
69
|
-
#
|
|
70
|
+
# A bare string: a literal (see Combinators#literal_matcher). What a
|
|
71
|
+
# literal matches depends on the grammar's ignore rule, so the grammar
|
|
72
|
+
# supplies the real matcher when this one runs.
|
|
73
|
+
Lit = Data.define(:text) do
|
|
74
|
+
include Matcher
|
|
75
|
+
|
|
76
|
+
def match(ctx, pos) = ctx.grammar.literal_matcher(text).match(ctx, pos)
|
|
77
|
+
def to_ast = [:str, text]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Exact string, nothing skipped (see Combinators#str).
|
|
70
81
|
Str = Data.define(:str) do
|
|
71
82
|
include Matcher
|
|
72
83
|
|
|
@@ -100,11 +111,17 @@ module Birman
|
|
|
100
111
|
# Sequence: parts match in order. The value is an array, or a hash
|
|
101
112
|
# of the named parts if any are named (see Matcher#as). Parts at
|
|
102
113
|
# index `commit_at` and beyond are committed: if one fails, parsing stops.
|
|
103
|
-
Seq = Data.define(:parts, :commit_at) do
|
|
114
|
+
Seq = Data.define(:parts, :commit_at, :only_at) do
|
|
104
115
|
include Matcher
|
|
105
116
|
|
|
106
117
|
# Arrays are copied and frozen so a matcher is immutable all the way down.
|
|
107
|
-
def initialize(parts:, commit_at: nil
|
|
118
|
+
def initialize(parts:, commit_at: nil, only_at: nil)
|
|
119
|
+
if only_at && parts.each_with_index.any? { |part, i| i != only_at && part.capture_key }
|
|
120
|
+
raise ArgumentError, "a seq with only(...) can't also name parts with .as"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
super(parts: parts.dup.freeze, commit_at:, only_at:)
|
|
124
|
+
end
|
|
108
125
|
|
|
109
126
|
def >>(other) = with(parts: [*parts, Birman.coerce(other)])
|
|
110
127
|
|
|
@@ -123,6 +140,9 @@ module Birman
|
|
|
123
140
|
|
|
124
141
|
def to_ast = [:seq, commit_at, parts.map(&:to_ast)]
|
|
125
142
|
|
|
143
|
+
# With only(...), the kept part's name (if any) passes outward.
|
|
144
|
+
def capture_key = only_at && parts[only_at].capture_key
|
|
145
|
+
|
|
126
146
|
private
|
|
127
147
|
|
|
128
148
|
# If the failed part recorded nothing at or past pos (it's quiet,
|
|
@@ -134,6 +154,8 @@ module Birman
|
|
|
134
154
|
end
|
|
135
155
|
|
|
136
156
|
def shape(values)
|
|
157
|
+
return values[only_at] if only_at
|
|
158
|
+
|
|
137
159
|
keys = parts.map(&:capture_key)
|
|
138
160
|
return values if keys.none?
|
|
139
161
|
|
|
@@ -213,7 +235,13 @@ module Birman
|
|
|
213
235
|
values = []
|
|
214
236
|
while max.nil? || values.size < max
|
|
215
237
|
r = inner.match(ctx, pos) or break
|
|
216
|
-
|
|
238
|
+
# Matching nothing ends the loop (repeating it would never stop).
|
|
239
|
+
# It still counts as the first match, so `x.rep(1)` and `x.maybe`
|
|
240
|
+
# work when x can match empty; after real matches it's just the end.
|
|
241
|
+
if r.pos == pos
|
|
242
|
+
values << r.value if values.empty?
|
|
243
|
+
break
|
|
244
|
+
end
|
|
217
245
|
|
|
218
246
|
values << r.value
|
|
219
247
|
pos = r.pos
|
|
@@ -269,8 +297,9 @@ module Birman
|
|
|
269
297
|
def capture_key = inner.capture_key
|
|
270
298
|
end
|
|
271
299
|
|
|
272
|
-
# Names a matcher for error messages. If inner fails
|
|
273
|
-
#
|
|
300
|
+
# Names a matcher for error messages. If inner fails (or succeeds
|
|
301
|
+
# while consuming nothing) without getting past its starting
|
|
302
|
+
# position, the error says "expected <name>"
|
|
274
303
|
# instead of listing every low-level alternative. If it got further,
|
|
275
304
|
# the deeper (more specific) error is kept.
|
|
276
305
|
Label = Data.define(:inner, :name) do
|
|
@@ -278,7 +307,7 @@ module Birman
|
|
|
278
307
|
|
|
279
308
|
def match(ctx, pos)
|
|
280
309
|
result, furthest, expected = ctx.isolate(pos) { inner.match(ctx, pos) }
|
|
281
|
-
if result.nil?
|
|
310
|
+
if furthest == pos && (result.nil? || (result.pos == pos && expected.any?))
|
|
282
311
|
ctx.fail_at(pos, name)
|
|
283
312
|
else
|
|
284
313
|
ctx.merge(furthest, expected)
|
data/lib/birman/version.rb
CHANGED