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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b986fd1949d2c3755859b6acd8753027417e40a382e18a0bdd02e350133bb1fa
4
- data.tar.gz: cd8f04b2b86f234cf915b7b1ad941574f03bfe4f2f1f35fd6488bc94ec27911a
3
+ metadata.gz: dffa403d5b8db3f4c977235997baedda18400899c9f1e692bf196bc6bb645152
4
+ data.tar.gz: e62c15487c0f6d1c4494e9103a198677379772f5904112877f8988c41f6be4b2
5
5
  SHA512:
6
- metadata.gz: 430c9e5d8b26483919f9eb0caaf24c39614752b117e4e61b45faa2919598453a75558a6e928d592055b765563a5fe1a531022ce4311687cc3689bdc6c3f961b2
7
- data.tar.gz: 7856edc44f834226c32f014a467de9d89fd5f4e4e840475f3c14b021199ae6de4b62b59520fc5eb9d813e17eb4e521e1e64fc3d74c7db1b49ea983b4ed090d1c
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
+ [![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.
@@ -68,7 +71,8 @@ end
68
71
  ```
69
72
 
70
73
  See `examples/calc.rb` for a complete small language with an
71
- interpreter, and `Grammar.to_ebnf` to print any grammar.
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
- - 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`.
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 # install dependencies
179
- bundle exec rake # tests + rubocop
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
@@ -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
 
@@ -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.0"
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tad Thorley