birman 0.1.0 → 0.2.0
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 +16 -0
- data/README.md +16 -6
- data/lib/birman/combinators.rb +42 -15
- data/lib/birman/matchers.rb +27 -5
- 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: dffa403d5b8db3f4c977235997baedda18400899c9f1e692bf196bc6bb645152
|
|
4
|
+
data.tar.gz: e62c15487c0f6d1c4494e9103a198677379772f5904112877f8988c41f6be4b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 51c0f918fec494fc1ef09b9af553f8824d486d2861d6a16a1b0bd06f273b20599f4d92844c2232c93dfd72e3b1635d3d8546e3ddee227b8a6406cebd7d67dbaf
|
|
7
|
+
data.tar.gz: 39a35b19fb92866e48b8fc80e308af33d70c105f108fa5ca093e35e0df7aa0d8ef29c11922d8cf66ac14baec6263b2f494765d2b4888fe538fb706188f8ef802
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-10-17
|
|
4
|
+
|
|
5
|
+
- **Added:** `one_of` takes a hash mapping literals to the values they
|
|
6
|
+
produce: `one_of("true" => true, "false" => false, "null" => nil)`.
|
|
7
|
+
- **Added:** `only(x)` inside `seq` makes that part the sequence's
|
|
8
|
+
whole value; the other parts must still match but are dropped:
|
|
9
|
+
`seq("[", commit, only(sep_by(value, ",")), "]")`. Its `.as` name, if
|
|
10
|
+
any, passes outward. Using it twice, mixing it with other `.as`
|
|
11
|
+
names, or using it outside `seq` raises `ArgumentError`.
|
|
12
|
+
- **Changed:** a bare string is a literal everywhere a matcher goes, not
|
|
13
|
+
just inside `seq`, `either` and the other helpers. A string used as a
|
|
14
|
+
rule body (`rule(:semi) { ";" }`) or after `>>`, `|`, `&` or `>` now
|
|
15
|
+
skips trailing spacing and, if word-like, is a keyword. Before, it
|
|
16
|
+
matched exactly those characters, so `seq(semi, "x")` failed on
|
|
17
|
+
`"; x"`. Use `str("...")` for the old exact match.
|
|
18
|
+
|
|
3
19
|
## [0.1.0] - 2026-10-17
|
|
4
20
|
|
|
5
21
|
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.
|
|
@@ -68,7 +71,8 @@ end
|
|
|
68
71
|
```
|
|
69
72
|
|
|
70
73
|
See `examples/calc.rb` for a complete small language with an
|
|
71
|
-
interpreter,
|
|
74
|
+
interpreter, `examples/json.rb` for a full JSON parser (tested against
|
|
75
|
+
`JSON.parse`), and `Grammar.to_ebnf` to print any grammar.
|
|
72
76
|
|
|
73
77
|
## Building blocks
|
|
74
78
|
|
|
@@ -76,7 +80,9 @@ interpreter, and `Grammar.to_ebnf` to print any grammar.
|
|
|
76
80
|
|-------------------------------|------------------------------------------------------|
|
|
77
81
|
| `seq(a, b, c)` | sequence |
|
|
78
82
|
| `seq(a, commit, b, c)` | after `a`, the rest must match or parsing stops here |
|
|
83
|
+
| `seq("[", only(x), "]")` | value is just `x`; the rest must match but is dropped |
|
|
79
84
|
| `either(a, b, c)` | ordered choice |
|
|
85
|
+
| `one_of("true" => true, ...)` | one of these literals, producing its value (tried in order) |
|
|
80
86
|
| `x.as(:key)` | name a part; the sequence's value becomes a hash |
|
|
81
87
|
| `x.to(Node)` | build a node from that hash (or from a single value) |
|
|
82
88
|
| `infix(operand, "+", "-", node: BinOp)` | left-associative binary operators; `BinOp` needs `op`, `left`, `right` fields |
|
|
@@ -97,9 +103,11 @@ first: `>>`, `&`, `|`, `>`.
|
|
|
97
103
|
Declare `ignore :spacing` and whitespace (and comments, with
|
|
98
104
|
`Rules::Comments`) takes care of itself:
|
|
99
105
|
|
|
100
|
-
-
|
|
101
|
-
|
|
102
|
-
|
|
106
|
+
- Bare strings are **literals** wherever a matcher goes: in `seq`,
|
|
107
|
+
`either`, `parens` and friends, as a rule body (`rule(:semi) { ";" }`),
|
|
108
|
+
or after `>>`, `|`, `&` and `>`. They skip trailing spacing, and
|
|
109
|
+
word-like ones (`"let"`) are keywords, so `"let"` won't match the
|
|
110
|
+
start of `letter`.
|
|
103
111
|
- `str("...")` and `match(/.../)` are **raw**: exactly those characters,
|
|
104
112
|
no skipping.
|
|
105
113
|
- Rules for tokens (numbers, names, strings) should be declared with
|
|
@@ -175,9 +183,11 @@ has one.
|
|
|
175
183
|
## Development
|
|
176
184
|
|
|
177
185
|
```sh
|
|
178
|
-
bin/setup
|
|
179
|
-
bundle exec rake
|
|
186
|
+
bin/setup # install dependencies
|
|
187
|
+
bundle exec rake # tests + rubocop
|
|
180
188
|
ruby examples/calc.rb
|
|
189
|
+
ruby examples/json.rb
|
|
190
|
+
ruby bench/json.rb # vs JSON.parse, Parslet, Treetop, Citrus (installs them)
|
|
181
191
|
```
|
|
182
192
|
|
|
183
193
|
## 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
|
|
data/lib/birman/version.rb
CHANGED