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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b986fd1949d2c3755859b6acd8753027417e40a382e18a0bdd02e350133bb1fa
4
- data.tar.gz: cd8f04b2b86f234cf915b7b1ad941574f03bfe4f2f1f35fd6488bc94ec27911a
3
+ metadata.gz: 6e56dfecb27c1584f8aefdb873503084e140abf83d53f2b85ed453ad1e25dbbe
4
+ data.tar.gz: 35b4ee8725298c3809fbde953f51fca4c550ac207a68a2eade43ad7745e02b15
5
5
  SHA512:
6
- metadata.gz: 430c9e5d8b26483919f9eb0caaf24c39614752b117e4e61b45faa2919598453a75558a6e928d592055b765563a5fe1a531022ce4311687cc3689bdc6c3f961b2
7
- data.tar.gz: 7856edc44f834226c32f014a467de9d89fd5f4e4e840475f3c14b021199ae6de4b62b59520fc5eb9d813e17eb4e521e1e64fc3d74c7db1b49ea983b4ed090d1c
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
+ [![Gem Version](https://img.shields.io/gem/v/birman)](https://rubygems.org/gems/birman)
8
+ [![CI](https://github.com/phaedryx/birman/actions/workflows/main.yml/badge.svg)](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, and `Grammar.to_ebnf` to print any grammar.
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
- - Strings passed to `seq`, `either`, `infix`, `parens` and friends are
101
- **literals**. They skip trailing spacing, and word-like ones
102
- (`"let"`) are keywords, so `"let"` won't match the start of `letter`.
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 # install dependencies
179
- bundle exec rake # tests + rubocop
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
@@ -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
- # Strings passed to seq, either, infix, one_of, sep_by, between and
12
- # parens are literals (see #lit): they skip trailing spacing when
13
- # the grammar declares `ignore`, and word-like ones are keywords. Use
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
- # A string literal as a token. "let" becomes a keyword, "=" an
30
- # exact string; either way followed by the grammar's ignore rule.
31
- # Non-strings pass through unchanged.
32
- def lit(x)
33
- return Birman.coerce(x) unless x.is_a?(String)
34
-
35
- base = x.match?(/\A\w+\z/) ? keyword(x) : str(x)
36
- Lexeme.new(base, ignore_rule, x)
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) }.map { lit(_1) }
46
- Seq.new(parts, commit_at)
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
- def one_of(*strings) = either(*strings)
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
@@ -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 Str.new(x)
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
- # Literal string.
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) = super(parts: parts.dup.freeze, commit_at:)
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
- break if r.pos == pos # an empty match would loop forever
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 without getting
273
- # past its starting position, the error says "expected <name>"
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? && furthest == pos
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Birman
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: birman
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tad Thorley