birman 0.2.1 → 0.2.2
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 +10 -0
- data/README.md +26 -3
- data/lib/birman/combinators.rb +85 -4
- data/lib/birman/context.rb +31 -18
- data/lib/birman/error_formatter.rb +6 -0
- data/lib/birman/grammar.rb +48 -3
- data/lib/birman/matchers.rb +97 -26
- data/lib/birman/nodes.rb +17 -0
- data/lib/birman/result.rb +32 -2
- data/lib/birman/rule_set.rb +19 -0
- data/lib/birman/rules.rb +17 -0
- data/lib/birman/version.rb +1 -1
- data/lib/birman.rb +8 -0
- 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: 9b052c872052f6d727f345e06626e7a11f080717c04474bb6bfdcd3a2ad9464a
|
|
4
|
+
data.tar.gz: 0daa868d84a98399a7ed835301ae320962d9867b949aa83e1dc830e1322fab7c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b3c9f4bf0d79656d051c51fd105634272d61ba0d05cf9d5e770fcd6326463011dcfdd94f3c158be2729729dacfb281d8e4fc0b16446aa3e2999780549604a926
|
|
7
|
+
data.tar.gz: 3f62acbd983729b04bb3d632a6b7cb37925f0d3c0d4f24ca116f7c389576e4829ff88bd53cd4a562fe23de26c6af386eeb2df018203c2bb767e81b9eeb21162b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.2] - 2026-10-17
|
|
4
|
+
|
|
5
|
+
- **Faster:** about 1.75x on the JSON benchmark (159 KB: 0.37s to 0.21s),
|
|
6
|
+
from allocating 60% fewer objects per parse: no temporary arrays in
|
|
7
|
+
failure tracking, a per-rule memo table instead of `[rule, pos]` keys,
|
|
8
|
+
a plain `Match` class instead of `Data`, and literals checked in place
|
|
9
|
+
instead of sliced out. No API changes.
|
|
10
|
+
- API documentation as TOML comment blocks in `lib/`, and a Limits
|
|
11
|
+
section in the README.
|
|
12
|
+
|
|
3
13
|
## [0.2.1] - 2026-10-17
|
|
4
14
|
|
|
5
15
|
- **Fixed:** `rep` and `maybe` over something that can match empty.
|
data/README.md
CHANGED
|
@@ -20,7 +20,7 @@ bundle add birman
|
|
|
20
20
|
or in your Gemfile:
|
|
21
21
|
|
|
22
22
|
```ruby
|
|
23
|
-
gem "birman", "~> 0.2.
|
|
23
|
+
gem "birman", "~> 0.2.2"
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
```ruby
|
|
@@ -165,8 +165,8 @@ has one.
|
|
|
165
165
|
them; pass `nil` to clear one (`ignore nil`, `keywords nil`)
|
|
166
166
|
- `Grammar.parse` returns `Success`/`Failure`; `parse!` raises `ParseError`
|
|
167
167
|
(`parse!(input, formatter: f)` words it your way).
|
|
168
|
-
Input nested too deeply for Ruby's stack
|
|
169
|
-
|
|
168
|
+
Input nested too deeply for Ruby's stack is a `Failure` too, with
|
|
169
|
+
`reason` set (see Limits).
|
|
170
170
|
- `Failure#message(formatter)` takes anything with `#call(failure)`
|
|
171
171
|
- `Grammar.to_ast` and `Grammar.to_ebnf` for introspection
|
|
172
172
|
- Rules are methods, so they compose and override through `include`.
|
|
@@ -178,6 +178,29 @@ has one.
|
|
|
178
178
|
`class`, `match`, `token`...). Defining one raises `ArgumentError`
|
|
179
179
|
instead of silently breaking the grammar. Redefining a rule is fine.
|
|
180
180
|
|
|
181
|
+
## Limits
|
|
182
|
+
|
|
183
|
+
- **Deep nesting.** Each level of nesting in the input uses Ruby
|
|
184
|
+
stack. With default settings a simple nested grammar handles about
|
|
185
|
+
750 levels on the main thread, but only about 250 on other threads
|
|
186
|
+
(Puma, Sidekiq, `Thread.new`). Past that, `parse` returns a `Failure`
|
|
187
|
+
whose `reason` is "input is nested too deeply". To allow more, raise
|
|
188
|
+
Ruby's thread stack sizes when starting the process:
|
|
189
|
+
|
|
190
|
+
```sh
|
|
191
|
+
RUBY_THREAD_VM_STACK_SIZE=16777216 RUBY_THREAD_MACHINE_STACK_SIZE=67108864 ruby app.rb
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
That took the same grammar to about 12,000 levels on a thread. The
|
|
195
|
+
main thread gets less (about 2,000), because its machine stack comes
|
|
196
|
+
from the operating system (`ulimit -s`).
|
|
197
|
+
- **Changing rules after parsing.** `Grammar.parse` reuses one
|
|
198
|
+
instance per class, so each rule is built once. Defining a rule or
|
|
199
|
+
changing a setting on a class resets that class's instance, but a
|
|
200
|
+
subclass that has already parsed keeps its own: it won't see a rule
|
|
201
|
+
redefined later on its parent. Define rules before parsing, or call
|
|
202
|
+
`Subclass.new.parse(input)` to get a fresh instance.
|
|
203
|
+
|
|
181
204
|
## Not yet
|
|
182
205
|
|
|
183
206
|
- Left recursion. A rule that can reach itself without consuming input
|
data/lib/birman/combinators.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# [file]
|
|
4
|
+
# purpose = "The combinators available inside every rule body"
|
|
5
|
+
# responsibility = "Building matchers: raw strings and patterns, literals, sequences, choices, lists, wrappers"
|
|
6
|
+
# dependencies = ["Birman::Matcher", "matchers.rb (Seq, Choice, Pick, SepBy, Infix, Lexeme)"]
|
|
7
|
+
# pattern = "Parser combinators (PEG)"
|
|
3
8
|
module Birman
|
|
4
9
|
# Marker for seq: everything after it is committed (see Matcher#>).
|
|
5
10
|
COMMIT = Object.new.tap { |o| def o.inspect = "Birman::COMMIT" }.freeze
|
|
@@ -15,27 +20,63 @@ module Birman
|
|
|
15
20
|
# a rule body, after >> or |) is a literal: it skips trailing spacing
|
|
16
21
|
# when the grammar declares `ignore`, and word-like ones are keywords.
|
|
17
22
|
# Use str and match for exact, raw matching.
|
|
23
|
+
# [module.Combinators]
|
|
24
|
+
# purpose = "Matcher-building methods mixed into every grammar (via Birman::Grammar)"
|
|
25
|
+
# input = "Strings (literals), Regexps (raw patterns) and matchers"
|
|
26
|
+
# output = "Immutable matcher objects; nothing is matched until Grammar#parse"
|
|
27
|
+
# notes = "Method names here are reserved: a rule can't share one"
|
|
18
28
|
module Combinators
|
|
19
29
|
# --- Raw matchers ---
|
|
20
30
|
|
|
21
|
-
|
|
31
|
+
# [method.str]
|
|
32
|
+
# params = ["s: String"]
|
|
33
|
+
# returns = "Matcher that matches exactly s; never skips spacing, never a keyword"
|
|
34
|
+
# value = "s"
|
|
35
|
+
def str(s) = Str.new(s)
|
|
36
|
+
|
|
37
|
+
# [method.match]
|
|
38
|
+
# params = ["re: Regexp (anchored at the current position; \\A, ^ and lookbehind see the whole input)"]
|
|
39
|
+
# returns = "Matcher for the regexp; raw, nothing skipped"
|
|
40
|
+
# value = "the matched text"
|
|
22
41
|
def match(re) = Pat.new(re)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
42
|
+
|
|
43
|
+
# [method.any]
|
|
44
|
+
# returns = "Matcher for any one character, newlines included"
|
|
45
|
+
def any = Pat.new(/./m)
|
|
46
|
+
|
|
47
|
+
# [method.eof]
|
|
48
|
+
# returns = "Matcher that succeeds only at the end of the input, consuming nothing"
|
|
49
|
+
def eof = any.absent.label("end of input")
|
|
50
|
+
|
|
51
|
+
# [method.never]
|
|
52
|
+
# returns = "Matcher that always fails (a placeholder for an empty choice)"
|
|
53
|
+
def never = Pat.new(/(?!)/)
|
|
26
54
|
|
|
27
55
|
# A whole word: "if" matches in "if x" but not in "iffy".
|
|
56
|
+
# [method.keyword]
|
|
57
|
+
# params = ["word: String"]
|
|
58
|
+
# returns = "Matcher for word when not followed by a word character"
|
|
59
|
+
# value = "word"
|
|
28
60
|
def keyword(word) = (str(word) >> match(/\w/).absent).map(&:first).label(word.inspect)
|
|
29
61
|
|
|
30
62
|
# --- Literals and the readable forms ---
|
|
31
63
|
|
|
32
64
|
# Anything usable as a matcher: strings become literals, regexps
|
|
33
65
|
# raw patterns, and matchers pass through unchanged.
|
|
66
|
+
# [method.lit]
|
|
67
|
+
# params = ["x: String | Regexp | Matcher"]
|
|
68
|
+
# returns = "A matcher: String -> literal, Regexp -> raw pattern, matcher unchanged"
|
|
69
|
+
# raises = ["ArgumentError (anything else, or only(...) outside seq)"]
|
|
34
70
|
def lit(x) = Birman.coerce(x)
|
|
35
71
|
|
|
36
72
|
# The matcher a literal stands for: "let" is a keyword, "=" an exact
|
|
37
73
|
# string, either way followed by the grammar's ignore rule. Built
|
|
38
74
|
# once per grammar instance.
|
|
75
|
+
# [method.literal_matcher]
|
|
76
|
+
# params = ["text: String"]
|
|
77
|
+
# returns = "The matcher a literal stands for: keyword or exact string, then the ignore rule"
|
|
78
|
+
# side_effects = ["Caches the matcher on the grammar instance"]
|
|
79
|
+
# notes = "Called by Lit at match time; rarely needed directly"
|
|
39
80
|
def literal_matcher(text)
|
|
40
81
|
(@literal_matchers ||= {})[text] ||= begin
|
|
41
82
|
base = text.match?(/\A\w+\z/) ? keyword(text) : str(text)
|
|
@@ -45,6 +86,11 @@ module Birman
|
|
|
45
86
|
|
|
46
87
|
# Sequence. Put `commit` where the construct becomes unambiguous:
|
|
47
88
|
# seq("let", commit, name.as(:name), "=", expr.as(:value))
|
|
89
|
+
# [method.seq]
|
|
90
|
+
# params = ["*items: String | Regexp | Matcher | commit | only(...)"]
|
|
91
|
+
# returns = "Matcher for the items in order"
|
|
92
|
+
# value = "Array of item values; a Hash of the .as-named parts if any; the only(...) part's value if used"
|
|
93
|
+
# raises = ["ArgumentError (commit first; more than one only; only mixed with .as names)"]
|
|
48
94
|
def seq(*items)
|
|
49
95
|
commit_at = items.index(COMMIT)
|
|
50
96
|
raise ArgumentError, "commit can't come first in a seq: nothing has matched yet" if commit_at&.zero?
|
|
@@ -54,19 +100,34 @@ module Birman
|
|
|
54
100
|
Seq.new(parts.map { _1.is_a?(Only) ? _1.matcher : lit(_1) }, commit_at, only_at)
|
|
55
101
|
end
|
|
56
102
|
|
|
103
|
+
# [method.commit]
|
|
104
|
+
# returns = "Marker for seq: if anything after it fails, the whole parse fails there"
|
|
105
|
+
# notes = "Lookaheads contain a commit failure; rep and maybe don't"
|
|
57
106
|
def commit = COMMIT
|
|
58
107
|
|
|
59
108
|
# Inside seq, makes this part the seq's whole value; the rest still
|
|
60
109
|
# has to match but is dropped:
|
|
61
110
|
# seq("[", commit, only(sep_by(value, ",")), "]") # => [...]
|
|
111
|
+
# [method.only]
|
|
112
|
+
# params = ["x: String | Regexp | Matcher"]
|
|
113
|
+
# returns = "Marker for seq making x's value the seq's whole value"
|
|
114
|
+
# notes = "x's .as name, if any, passes to the enclosing seq"
|
|
62
115
|
def only(x) = Only.new(lit(x))
|
|
63
116
|
|
|
64
117
|
# Ordered choice: the first alternative that matches wins.
|
|
118
|
+
# [method.either]
|
|
119
|
+
# params = ["*alts: String | Regexp | Matcher"]
|
|
120
|
+
# returns = "Ordered choice: the first alternative that matches wins"
|
|
121
|
+
# value = "The winning alternative's value"
|
|
65
122
|
def either(*alts) = alts.size == 1 ? lit(alts.first) : Choice.new(alts.map { lit(_1) })
|
|
66
123
|
|
|
67
124
|
# One of several literals. Given a hash, each literal produces its
|
|
68
125
|
# value: one_of("true" => true, "null" => nil). They're tried in
|
|
69
126
|
# order, so put longer ones first ("<=" before "<").
|
|
127
|
+
# [method.one_of]
|
|
128
|
+
# params = ["*choices: String literals", "or one Hash of literal => value"]
|
|
129
|
+
# returns = "Ordered choice of the literals"
|
|
130
|
+
# value = "The matched literal, or its Hash value"
|
|
70
131
|
def one_of(*choices)
|
|
71
132
|
return either(*choices) unless choices in [Hash => table]
|
|
72
133
|
|
|
@@ -75,17 +136,37 @@ module Birman
|
|
|
75
136
|
|
|
76
137
|
# Left-associative binary operators, folded into nodes:
|
|
77
138
|
# infix(product, "+", "-", node: BinOp) # BinOp = node(:op, :left, :right)
|
|
139
|
+
# [method.infix]
|
|
140
|
+
# params = ["operand: Matcher", "*ops: String literals", "node: node class with op, left, right fields"]
|
|
141
|
+
# returns = "Matcher for operand (op operand)*, folded left-associatively"
|
|
142
|
+
# value = "operand's value, or nested node instances with spans"
|
|
143
|
+
# raises = ["ArgumentError at parse time if node lacks op/left/right"]
|
|
78
144
|
def infix(operand, *ops, node:) = Infix.new(Birman.coerce(operand), either(*ops).label("operator"), node)
|
|
79
145
|
|
|
80
146
|
# One or more items separated by sep; value is an array of items.
|
|
147
|
+
# [method.sep_by1]
|
|
148
|
+
# params = ["item: String | Regexp | Matcher", "sep: String | Regexp | Matcher"]
|
|
149
|
+
# returns = "Matcher for one or more items separated by sep"
|
|
150
|
+
# value = "Array of item values (item's .as name passes outward)"
|
|
81
151
|
def sep_by1(item, sep) = SepBy.new(lit(item), lit(sep))
|
|
82
152
|
|
|
83
153
|
# Zero or more items separated by sep.
|
|
154
|
+
# [method.sep_by]
|
|
155
|
+
# params = ["item: String | Regexp | Matcher", "sep: String | Regexp | Matcher"]
|
|
156
|
+
# returns = "Matcher for zero or more items separated by sep"
|
|
157
|
+
# value = "Array of item values, [] when there are none"
|
|
84
158
|
def sep_by(item, sep) = sep_by1(item, sep).maybe.map { _1 || [] }
|
|
85
159
|
|
|
86
160
|
# inner wrapped in open/close; value is inner's.
|
|
161
|
+
# [method.between]
|
|
162
|
+
# params = ["open: String | Matcher", "close: String | Matcher", "inner: Matcher"]
|
|
163
|
+
# returns = "Matcher for open inner close"
|
|
164
|
+
# value = "inner's value (inner's .as name passes outward)"
|
|
87
165
|
def between(open, close, inner) = Pick.new([lit(open), Birman.coerce(inner), lit(close)], 1)
|
|
88
166
|
|
|
167
|
+
# [method.parens]
|
|
168
|
+
# params = ["inner: Matcher"]
|
|
169
|
+
# returns = "between(\"(\", \")\", inner)"
|
|
89
170
|
def parens(inner) = between("(", ")", inner)
|
|
90
171
|
|
|
91
172
|
private
|
data/lib/birman/context.rb
CHANGED
|
@@ -12,16 +12,20 @@ module Birman
|
|
|
12
12
|
# Marks a memo entry whose rule is still being matched.
|
|
13
13
|
IN_PROGRESS = Object.new.freeze
|
|
14
14
|
|
|
15
|
+
# The expectations of a frame that hasn't failed anywhere yet. Shared
|
|
16
|
+
# and frozen; fail_at replaces it rather than appending.
|
|
17
|
+
NOTHING_EXPECTED = [].freeze
|
|
18
|
+
|
|
15
19
|
# position: where the most recently entered rule started. When the
|
|
16
20
|
# input nests too deeply for Ruby's stack, that's where it happened.
|
|
17
|
-
attr_reader :input, :grammar, :
|
|
21
|
+
attr_reader :input, :grammar, :furthest, :expected, :position, :isolated_furthest, :isolated_expected
|
|
18
22
|
|
|
19
23
|
def initialize(input, grammar)
|
|
20
24
|
@input = input
|
|
21
25
|
@grammar = grammar
|
|
22
26
|
@memo = {}
|
|
23
27
|
@furthest = 0
|
|
24
|
-
@expected =
|
|
28
|
+
@expected = NOTHING_EXPECTED
|
|
25
29
|
@token_ends = {}
|
|
26
30
|
end
|
|
27
31
|
|
|
@@ -46,7 +50,7 @@ module Birman
|
|
|
46
50
|
@furthest = pos
|
|
47
51
|
@expected = [what]
|
|
48
52
|
elsif pos == @furthest
|
|
49
|
-
@expected << what
|
|
53
|
+
@expected = @expected.frozen? ? [what] : @expected << what
|
|
50
54
|
end
|
|
51
55
|
nil
|
|
52
56
|
end
|
|
@@ -54,17 +58,22 @@ module Birman
|
|
|
54
58
|
def merge(pos, expected) = expected.each { fail_at(pos, _1) }
|
|
55
59
|
|
|
56
60
|
# Run the block with fresh failure tracking starting at pos, then
|
|
57
|
-
# restore the outer state
|
|
58
|
-
#
|
|
59
|
-
#
|
|
61
|
+
# restore the outer state, and return the block's result. What the
|
|
62
|
+
# block recorded is left in isolated_furthest and isolated_expected
|
|
63
|
+
# for the caller to keep or drop: Ref memoizes it, Label rewrites
|
|
64
|
+
# it, Not throws it away. No arrays: this runs for every rule call.
|
|
60
65
|
def isolate(pos)
|
|
61
|
-
|
|
66
|
+
outer_furthest = @furthest
|
|
67
|
+
outer_expected = @expected
|
|
62
68
|
@furthest = pos
|
|
63
|
-
@expected =
|
|
69
|
+
@expected = NOTHING_EXPECTED
|
|
64
70
|
result = yield
|
|
65
|
-
|
|
71
|
+
@isolated_furthest = @furthest
|
|
72
|
+
@isolated_expected = @expected
|
|
73
|
+
result
|
|
66
74
|
ensure
|
|
67
|
-
@furthest
|
|
75
|
+
@furthest = outer_furthest
|
|
76
|
+
@expected = outer_expected
|
|
68
77
|
end
|
|
69
78
|
|
|
70
79
|
def commit_failure! = raise(Committed.new(@furthest, @expected.dup))
|
|
@@ -74,9 +83,12 @@ module Birman
|
|
|
74
83
|
# moves past, so they're dropped. If it fails, that's recorded at
|
|
75
84
|
# pos, as what it expected there or as the lookahead itself.
|
|
76
85
|
def peek?(matcher, pos)
|
|
77
|
-
result
|
|
86
|
+
result = isolate(pos) { lookahead { matcher.match(self, pos) } }
|
|
78
87
|
return true if result
|
|
79
88
|
|
|
89
|
+
furthest = isolated_furthest
|
|
90
|
+
expected = isolated_expected
|
|
91
|
+
|
|
80
92
|
if furthest == pos && expected.any?
|
|
81
93
|
merge(pos, expected)
|
|
82
94
|
else
|
|
@@ -91,13 +103,13 @@ module Birman
|
|
|
91
103
|
# input: left recursion, which would never terminate. That's a bug
|
|
92
104
|
# in the grammar, not the input, so it raises.
|
|
93
105
|
def memoize(name, pos, &)
|
|
94
|
-
|
|
95
|
-
entry =
|
|
106
|
+
table = @memo[name] ||= {}
|
|
107
|
+
entry = table[pos]
|
|
96
108
|
return entry if entry && !entry.equal?(IN_PROGRESS)
|
|
97
109
|
raise ArgumentError, "left recursion in rule :#{name}: it can reach itself without consuming input" if entry
|
|
98
110
|
|
|
99
111
|
@position = pos
|
|
100
|
-
compute(
|
|
112
|
+
compute(table, pos, &)
|
|
101
113
|
end
|
|
102
114
|
|
|
103
115
|
# Run a lookahead's inner match. A lookahead only asks whether
|
|
@@ -111,13 +123,14 @@ module Birman
|
|
|
111
123
|
|
|
112
124
|
private
|
|
113
125
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
126
|
+
# The memo is a Hash per rule, keyed by position, so no key arrays.
|
|
127
|
+
def compute(table, pos)
|
|
128
|
+
table[pos] = IN_PROGRESS
|
|
129
|
+
table[pos] = yield
|
|
117
130
|
ensure
|
|
118
131
|
# A commit failure caught by a lookahead unwinds through here; the
|
|
119
132
|
# rule may be tried again at this position afterwards.
|
|
120
|
-
|
|
133
|
+
table.delete(pos) if table[pos].equal?(IN_PROGRESS)
|
|
121
134
|
end
|
|
122
135
|
end
|
|
123
136
|
end
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# [file]
|
|
4
|
+
# purpose = "Default wording for parse errors"
|
|
5
|
+
# pattern = "Strategy: anything with #call(failure) can replace it"
|
|
3
6
|
module Birman
|
|
4
7
|
# Turns a Failure into readable text. Anything that responds to
|
|
5
8
|
# #call(failure) works in its place, for custom wording or i18n:
|
|
6
9
|
#
|
|
7
10
|
# failure.message(MyFormatter.new)
|
|
8
11
|
class ErrorFormatter
|
|
12
|
+
# [method.call]
|
|
13
|
+
# params = ["failure: Birman::Failure"]
|
|
14
|
+
# returns = "String: \"line L, column C: expected ..., found ...\" plus the source line and a caret"
|
|
9
15
|
def call(failure)
|
|
10
16
|
source = source_line(failure)
|
|
11
17
|
<<~MSG.chomp
|
data/lib/birman/grammar.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# [file]
|
|
4
|
+
# purpose = "Makes a class (or a module of shared rules) a grammar"
|
|
5
|
+
# responsibility = "Settings, parse entry points, rule lookup and introspection"
|
|
6
|
+
# dependencies = ["Birman::Combinators", "Birman::RuleSet", "Birman::Nodes", "Birman::Context"]
|
|
7
|
+
# pattern = "Mixin with class-level DSL"
|
|
3
8
|
module Birman
|
|
4
9
|
# Include this to make a class a grammar. The class body also gets
|
|
5
10
|
# `node` (see Nodes), so AST nodes can live beside their rules.
|
|
@@ -7,6 +12,10 @@ module Birman
|
|
|
7
12
|
# Including it in a module makes a module of shared rules: whatever
|
|
8
13
|
# includes that module (a class, or another such module) becomes a
|
|
9
14
|
# grammar too.
|
|
15
|
+
# [module.Grammar]
|
|
16
|
+
# purpose = "include this in a class to make it a grammar"
|
|
17
|
+
# provides = ["rule / lexeme (RuleSet)", "node (Nodes)", "combinators", "settings and parse"]
|
|
18
|
+
# collaborators = ["Context", "Success", "Failure", "ErrorFormatter"]
|
|
10
19
|
module Grammar
|
|
11
20
|
include Combinators
|
|
12
21
|
|
|
@@ -26,18 +35,30 @@ module Birman
|
|
|
26
35
|
|
|
27
36
|
# Settings are inherited by subclasses. Passing nil clears an
|
|
28
37
|
# inherited one: `ignore nil`, `keywords nil`.
|
|
38
|
+
# [module.Grammar.ClassMethods]
|
|
39
|
+
# purpose = "Settings and parse entry points on a grammar class"
|
|
40
|
+
# notes = "Settings are inherited by subclasses; pass nil to clear one"
|
|
29
41
|
module ClassMethods
|
|
30
42
|
UNSET = Object.new.freeze
|
|
31
43
|
|
|
32
44
|
# The rule a parse starts from.
|
|
45
|
+
# [method.root]
|
|
46
|
+
# params = ["name: Symbol | nil (omit to read)"]
|
|
47
|
+
# returns = "The start rule's name"
|
|
33
48
|
def root(name = UNSET) = setting(:root, name)
|
|
34
49
|
|
|
35
50
|
# A rule the parser matches and discards after every literal and
|
|
36
51
|
# lexeme, and at the start of input. Usually :spacing (from
|
|
37
52
|
# Birman::Rules::Whitespace or Comments).
|
|
53
|
+
# [method.ignore]
|
|
54
|
+
# params = ["name: Symbol | nil (omit to read)"]
|
|
55
|
+
# returns = "The ignore rule's name (matched and discarded after literals and lexemes)"
|
|
38
56
|
def ignore(name = UNSET) = setting(:ignore, name)
|
|
39
57
|
|
|
40
58
|
# Reserved words, which Birman::Rules::Identifiers won't accept.
|
|
59
|
+
# [method.keywords]
|
|
60
|
+
# params = ["*words: String (none to read; nil to clear)"]
|
|
61
|
+
# returns = "Array of reserved words (Rules::Identifiers rejects them as names)"
|
|
41
62
|
def keywords(*words)
|
|
42
63
|
return setting(:keywords) || [] if words.empty?
|
|
43
64
|
|
|
@@ -47,10 +68,25 @@ module Birman
|
|
|
47
68
|
# These share one instance, so each rule's matcher is built once
|
|
48
69
|
# rather than on every parse. Defining a rule or changing a
|
|
49
70
|
# setting starts a fresh one.
|
|
50
|
-
|
|
71
|
+
# [method."Grammar.parse"]
|
|
72
|
+
# params = ["input: String"]
|
|
73
|
+
# returns = "Success(value) or Failure; see Grammar#parse"
|
|
74
|
+
# notes = "Uses one cached instance; defining a rule or changing a setting resets it"
|
|
75
|
+
def parse(input) = instance.parse(input)
|
|
76
|
+
|
|
77
|
+
# [method."Grammar.parse!"]
|
|
78
|
+
# params = ["input: String", "formatter: #call(failure) = ErrorFormatter.new"]
|
|
79
|
+
# returns = "The parsed value"
|
|
80
|
+
# raises = ["Birman::ParseError"]
|
|
51
81
|
def parse!(input, **) = instance.parse!(input, **)
|
|
52
|
-
|
|
53
|
-
|
|
82
|
+
|
|
83
|
+
# [method."Grammar.to_ast"]
|
|
84
|
+
# returns = "Hash of rule name => AST, for every rule reachable from root and ignore"
|
|
85
|
+
def to_ast = instance.to_ast
|
|
86
|
+
|
|
87
|
+
# [method."Grammar.to_ebnf"]
|
|
88
|
+
# returns = "String: the grammar in PEG-flavored EBNF"
|
|
89
|
+
def to_ebnf = EBNF.call(to_ast)
|
|
54
90
|
|
|
55
91
|
private
|
|
56
92
|
|
|
@@ -71,6 +107,11 @@ module Birman
|
|
|
71
107
|
def keywords = self.class.keywords
|
|
72
108
|
|
|
73
109
|
# Returns Success(value) or Failure(...). Never raises on bad input.
|
|
110
|
+
# [method.parse]
|
|
111
|
+
# params = ["input: String"]
|
|
112
|
+
# returns = "Birman::Success(value) when all of input parses, else Birman::Failure"
|
|
113
|
+
# raises = ["ArgumentError for grammar mistakes (no root, undefined rule, left recursion)"]
|
|
114
|
+
# notes = "Bad input never raises: too-deep nesting is a Failure with reason set"
|
|
74
115
|
def parse(input)
|
|
75
116
|
ctx = Context.new(input, self)
|
|
76
117
|
start = (ignore_rule && Ref.new(ignore_rule).match(ctx, 0)&.pos) || 0
|
|
@@ -86,6 +127,10 @@ module Birman
|
|
|
86
127
|
end
|
|
87
128
|
|
|
88
129
|
# Returns the value or raises ParseError, worded by formatter.
|
|
130
|
+
# [method."parse!"]
|
|
131
|
+
# params = ["input: String", "formatter: #call(failure) = ErrorFormatter.new"]
|
|
132
|
+
# returns = "The parsed value"
|
|
133
|
+
# raises = ["Birman::ParseError (message from formatter)"]
|
|
89
134
|
def parse!(input, formatter: ErrorFormatter.new) = parse(input).value!(formatter)
|
|
90
135
|
|
|
91
136
|
def matcher_for(name)
|
data/lib/birman/matchers.rb
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# [file]
|
|
4
|
+
# purpose = "The matcher types and the methods every matcher responds to"
|
|
5
|
+
# responsibility = "Matching at a byte position against a Context; building ASTs for EBNF"
|
|
6
|
+
# dependencies = ["Birman::Context", "StringScanner (via Context#scanner)"]
|
|
7
|
+
# pattern = "Immutable Data objects composed into a tree; packrat memoization at rule refs"
|
|
8
|
+
# public_api = "Birman::Matcher (operators and modifiers below); the Data classes are internal"
|
|
3
9
|
module Birman
|
|
10
|
+
# [method."Birman.coerce"]
|
|
11
|
+
# params = ["x: String | Regexp | Matcher"]
|
|
12
|
+
# returns = "Matcher: String -> literal (Lit), Regexp -> raw pattern (Pat), matcher unchanged"
|
|
13
|
+
# raises = ["ArgumentError (only(...) outside seq, or anything else)"]
|
|
4
14
|
def self.coerce(x)
|
|
5
15
|
case x
|
|
6
16
|
when Matcher then x
|
|
@@ -22,41 +32,91 @@ module Birman
|
|
|
22
32
|
#
|
|
23
33
|
# So `a > b >> c` means `a > (b >> c)`, and a commit inside a choice
|
|
24
34
|
# needs parentheses: `(keyword("if") > body) | other`.
|
|
35
|
+
# [module.Matcher]
|
|
36
|
+
# purpose = "Operators and modifiers shared by every matcher"
|
|
37
|
+
# notes = "Arguments go through Birman.coerce, so bare strings are literals"
|
|
25
38
|
module Matcher
|
|
39
|
+
# [method.">>"]
|
|
40
|
+
# params = ["other: String | Regexp | Matcher"]
|
|
41
|
+
# returns = "Sequence of self then other (flattens into an existing sequence)"
|
|
26
42
|
def >>(other) = Seq.new([self, Birman.coerce(other)])
|
|
27
|
-
|
|
43
|
+
|
|
44
|
+
# [method."|"]
|
|
45
|
+
# params = ["other: String | Regexp | Matcher"]
|
|
46
|
+
# returns = "Ordered choice: self, else other"
|
|
47
|
+
def |(other) = Choice.new([self, Birman.coerce(other)])
|
|
28
48
|
|
|
29
49
|
# Conjunction: self must match here (without consuming input),
|
|
30
50
|
# then other is matched from the same spot. Value is other's.
|
|
51
|
+
# [method."&"]
|
|
52
|
+
# params = ["other: String | Regexp | Matcher"]
|
|
53
|
+
# returns = "self as a guard (lookahead), then other from the same position"
|
|
54
|
+
# value = "other's value"
|
|
31
55
|
def &(other) = Both.new(self, Birman.coerce(other))
|
|
32
56
|
|
|
33
57
|
# Commit: once self matches, everything after it must match too, or
|
|
34
58
|
# parsing stops right here with an error instead of backtracking.
|
|
35
59
|
# This is dry-logic's implication operator, applied to parsing.
|
|
60
|
+
# [method.">"]
|
|
61
|
+
# params = ["other: String | Regexp | Matcher"]
|
|
62
|
+
# returns = "Sequence of self then other, committed after self (like seq(self, commit, other))"
|
|
36
63
|
def >(other)
|
|
37
64
|
left = uncommitted_parts(self)
|
|
38
65
|
right = uncommitted_parts(Birman.coerce(other))
|
|
39
66
|
Seq.new([*left, *right], left.size)
|
|
40
67
|
end
|
|
41
68
|
|
|
69
|
+
# [method.rep]
|
|
70
|
+
# params = ["min: Integer = 0", "max: Integer | nil = nil (unbounded)"]
|
|
71
|
+
# returns = "Matcher for self repeated min..max times"
|
|
72
|
+
# value = "Array of values; an empty first match counts once, then ends the loop"
|
|
42
73
|
def rep(min = 0, max = nil) = Rep.new(self, min, max)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
74
|
+
|
|
75
|
+
# [method.maybe]
|
|
76
|
+
# returns = "Optional self"
|
|
77
|
+
# value = "self's value, or nil when absent"
|
|
78
|
+
def maybe = rep(0, 1).map(&:first)
|
|
79
|
+
|
|
80
|
+
# [method.absent]
|
|
81
|
+
# returns = "Negative lookahead: succeeds, consuming nothing, when self doesn't match here"
|
|
82
|
+
# value = "nil"
|
|
83
|
+
def absent = Not.new(self)
|
|
84
|
+
|
|
85
|
+
# [method.present]
|
|
86
|
+
# returns = "Positive lookahead: succeeds, consuming nothing, when self matches here"
|
|
87
|
+
# value = "nil"
|
|
88
|
+
def present = And.new(self)
|
|
89
|
+
|
|
90
|
+
# [method.map]
|
|
91
|
+
# params = ["fn: block taking self's value"]
|
|
92
|
+
# returns = "Matcher whose value is fn's result (self's .as name is kept)"
|
|
93
|
+
def map(&fn) = Action.new(self, fn)
|
|
94
|
+
|
|
95
|
+
# [method.label]
|
|
96
|
+
# params = ["name: String"]
|
|
97
|
+
# returns = "self, reported as \"expected <name>\" when it fails without consuming input"
|
|
47
98
|
def label(name) = Label.new(self, name)
|
|
48
99
|
|
|
49
100
|
# Never mention this matcher in error messages (for whitespace,
|
|
50
101
|
# comments, and other things nobody wants to be told to type).
|
|
102
|
+
# [method.quiet]
|
|
103
|
+
# returns = "self, never mentioned in error messages"
|
|
51
104
|
def quiet = Quiet.new(self)
|
|
52
105
|
|
|
53
106
|
# Name this part of a sequence. A sequence with any named parts
|
|
54
107
|
# produces a hash of just those parts instead of an array.
|
|
108
|
+
# [method.as]
|
|
109
|
+
# params = ["key: Symbol"]
|
|
110
|
+
# returns = "self, named: the enclosing seq's value becomes a Hash of named parts"
|
|
55
111
|
def as(key) = Named.new(self, key)
|
|
56
112
|
|
|
57
113
|
# Build a node from the value: a hash of named parts becomes
|
|
58
114
|
# keyword arguments; anything else fills the node's first field.
|
|
59
115
|
# Nodes made with Birman.node get a source span.
|
|
116
|
+
# [method.to]
|
|
117
|
+
# params = ["node: node class (or any Data class)"]
|
|
118
|
+
# returns = "Matcher whose value is node built from self's value"
|
|
119
|
+
# value = "node.new(**hash), or node.new(first_field => value); with a span for node(...) classes"
|
|
60
120
|
def to(node) = To.new(self, node)
|
|
61
121
|
|
|
62
122
|
# The name given by #as, seen through wrappers like labels.
|
|
@@ -81,12 +141,16 @@ module Birman
|
|
|
81
141
|
Str = Data.define(:str) do
|
|
82
142
|
include Matcher
|
|
83
143
|
|
|
144
|
+
# The error text is worked out once, not on every failed attempt.
|
|
145
|
+
def initialize(str:)
|
|
146
|
+
@expected = str.inspect.freeze
|
|
147
|
+
super
|
|
148
|
+
end
|
|
149
|
+
|
|
84
150
|
def match(ctx, pos)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
ctx.fail_at(pos, str.inspect)
|
|
89
|
-
end
|
|
151
|
+
scanner = ctx.scanner
|
|
152
|
+
scanner.pos = pos
|
|
153
|
+
scanner.match?(str) ? Match.new(pos + str.bytesize, str) : ctx.fail_at(pos, @expected)
|
|
90
154
|
end
|
|
91
155
|
|
|
92
156
|
def to_ast = [:str, str]
|
|
@@ -126,14 +190,11 @@ module Birman
|
|
|
126
190
|
def >>(other) = with(parts: [*parts, Birman.coerce(other)])
|
|
127
191
|
|
|
128
192
|
def match(ctx, pos)
|
|
129
|
-
values = parts.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
return nil
|
|
134
|
-
end
|
|
193
|
+
values = Array.new(parts.size)
|
|
194
|
+
parts.each_with_index do |part, i|
|
|
195
|
+
r = part.match(ctx, pos) or return failed(ctx, part, pos, i)
|
|
196
|
+
values[i] = r.value
|
|
135
197
|
pos = r.pos
|
|
136
|
-
r.value
|
|
137
198
|
end
|
|
138
199
|
Match.new(pos, shape(values))
|
|
139
200
|
end
|
|
@@ -145,6 +206,11 @@ module Birman
|
|
|
145
206
|
|
|
146
207
|
private
|
|
147
208
|
|
|
209
|
+
def failed(ctx, part, pos, index)
|
|
210
|
+
fail_committed!(ctx, part, pos) if commit_at && index >= commit_at
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
148
214
|
# If the failed part recorded nothing at or past pos (it's quiet,
|
|
149
215
|
# or a negative lookahead), describe the part itself so the error
|
|
150
216
|
# still says what was expected.
|
|
@@ -259,7 +325,7 @@ module Birman
|
|
|
259
325
|
include Matcher
|
|
260
326
|
|
|
261
327
|
def match(ctx, pos)
|
|
262
|
-
r
|
|
328
|
+
r = ctx.isolate(pos) { ctx.lookahead { inner.match(ctx, pos) } }
|
|
263
329
|
r ? nil : Match.new(pos, nil)
|
|
264
330
|
end
|
|
265
331
|
|
|
@@ -290,7 +356,7 @@ module Birman
|
|
|
290
356
|
|
|
291
357
|
def match(ctx, pos)
|
|
292
358
|
r = inner.match(ctx, pos) or return nil
|
|
293
|
-
r.
|
|
359
|
+
Match.new(r.pos, fn.call(r.value))
|
|
294
360
|
end
|
|
295
361
|
|
|
296
362
|
def to_ast = [:action, inner.to_ast]
|
|
@@ -306,7 +372,9 @@ module Birman
|
|
|
306
372
|
include Matcher
|
|
307
373
|
|
|
308
374
|
def match(ctx, pos)
|
|
309
|
-
result
|
|
375
|
+
result = ctx.isolate(pos) { inner.match(ctx, pos) }
|
|
376
|
+
furthest = ctx.isolated_furthest
|
|
377
|
+
expected = ctx.isolated_expected
|
|
310
378
|
if furthest == pos && (result.nil? || (result.pos == pos && expected.any?))
|
|
311
379
|
ctx.fail_at(pos, name)
|
|
312
380
|
else
|
|
@@ -336,7 +404,7 @@ module Birman
|
|
|
336
404
|
def match(ctx, pos)
|
|
337
405
|
r = inner.match(ctx, pos) or return nil
|
|
338
406
|
fields = r.value.is_a?(Hash) ? r.value : { node.members.first => r.value }
|
|
339
|
-
r.
|
|
407
|
+
Match.new(r.pos, Birman.build(node, fields, ctx, pos, r.pos))
|
|
340
408
|
end
|
|
341
409
|
|
|
342
410
|
def to_ast = [:action, inner.to_ast]
|
|
@@ -349,13 +417,16 @@ module Birman
|
|
|
349
417
|
Lexeme = Data.define(:inner, :ignore, :source) do
|
|
350
418
|
include Matcher
|
|
351
419
|
|
|
352
|
-
def initialize(inner:, ignore:, source: nil)
|
|
420
|
+
def initialize(inner:, ignore:, source: nil)
|
|
421
|
+
@ignore_ref = ignore && Ref.new(ignore) # built once, not per token
|
|
422
|
+
super
|
|
423
|
+
end
|
|
353
424
|
|
|
354
425
|
def match(ctx, pos)
|
|
355
426
|
r = inner.match(ctx, pos) or return nil
|
|
356
427
|
return r unless ignore
|
|
357
428
|
|
|
358
|
-
s =
|
|
429
|
+
s = @ignore_ref.match(ctx, r.pos) or return r
|
|
359
430
|
ctx.ignored_after(r.pos, s.pos)
|
|
360
431
|
Match.new(s.pos, r.value)
|
|
361
432
|
end
|
|
@@ -398,8 +469,7 @@ module Birman
|
|
|
398
469
|
include Matcher
|
|
399
470
|
|
|
400
471
|
def match(ctx, pos)
|
|
401
|
-
|
|
402
|
-
result
|
|
472
|
+
ctx.isolate(pos) { inner.match(ctx, pos) }
|
|
403
473
|
end
|
|
404
474
|
|
|
405
475
|
def to_ast = [:quiet, inner.to_ast]
|
|
@@ -414,7 +484,8 @@ module Birman
|
|
|
414
484
|
|
|
415
485
|
def match(ctx, pos)
|
|
416
486
|
result, furthest, expected = ctx.memoize(name, pos) do
|
|
417
|
-
ctx.isolate(pos) { ctx.grammar.matcher_for(name).match(ctx, pos) }
|
|
487
|
+
r = ctx.isolate(pos) { ctx.grammar.matcher_for(name).match(ctx, pos) }
|
|
488
|
+
[r, ctx.isolated_furthest, ctx.isolated_expected]
|
|
418
489
|
end
|
|
419
490
|
ctx.merge(furthest, expected)
|
|
420
491
|
result
|
data/lib/birman/nodes.rb
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# [file]
|
|
4
|
+
# purpose = "AST nodes with source spans"
|
|
5
|
+
# responsibility = "node(...) classes, Span, and building nodes during a parse"
|
|
6
|
+
# pattern = "Data classes plus a mixin"
|
|
3
7
|
module Birman
|
|
4
8
|
# Where a node came from in the source. Stored as byte offsets (see
|
|
5
9
|
# Context); from, to, line and column count characters.
|
|
10
|
+
# [class.Span]
|
|
11
|
+
# purpose = "Where a node came from in the input"
|
|
12
|
+
# fields = ["input: String", "byte_from, byte_to: Integer (byte offsets)"]
|
|
13
|
+
# methods = ["from, to: character offsets", "text", "line", "column", "to_s => \"line L, column C\""]
|
|
6
14
|
Span = Data.define(:input, :byte_from, :byte_to) do
|
|
7
15
|
def from = Birman.char_offset(input, byte_from)
|
|
8
16
|
def to = Birman.char_offset(input, byte_to)
|
|
@@ -22,6 +30,9 @@ module Birman
|
|
|
22
30
|
# not a Data member, so equality and pattern matching ignore it:
|
|
23
31
|
# `in Num(value)` still works, and two nodes with the same contents
|
|
24
32
|
# are equal wherever they came from.
|
|
33
|
+
# [module.Node]
|
|
34
|
+
# purpose = "Mixed into every node(...) class"
|
|
35
|
+
# adds = ["span (ignored by == and pattern matching)", "with(**changes) keeping the span", "s-expression inspect"]
|
|
25
36
|
module Node
|
|
26
37
|
attr_reader :span
|
|
27
38
|
|
|
@@ -61,7 +72,13 @@ module Birman
|
|
|
61
72
|
# end
|
|
62
73
|
#
|
|
63
74
|
# Also available as Birman.node.
|
|
75
|
+
# [module.Nodes]
|
|
76
|
+
# purpose = "Provides node(...); grammars get it automatically, other modules can extend it"
|
|
64
77
|
module Nodes
|
|
78
|
+
# [method.node]
|
|
79
|
+
# params = ["*members: Symbol (field names; not :span)", "body: optional block of methods (may define initialize)"]
|
|
80
|
+
# returns = "A Data class including Birman::Node"
|
|
81
|
+
# raises = ["ArgumentError if a field is named span"]
|
|
65
82
|
def node(*members, &body)
|
|
66
83
|
raise ArgumentError, "a node can't have a field named span: every node already has one" if members.include?(:span)
|
|
67
84
|
|
data/lib/birman/result.rb
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# [file]
|
|
4
|
+
# purpose = "What a parse returns"
|
|
5
|
+
# responsibility = "Success and Failure values, and the ParseError that parse! raises"
|
|
6
|
+
# pattern = "Result objects (Data), pattern-matchable"
|
|
3
7
|
module Birman
|
|
4
|
-
# Internal: one matcher succeeded, ending at `pos` with `value`.
|
|
5
|
-
|
|
8
|
+
# Internal: one matcher succeeded, ending at `pos` with `value`. A
|
|
9
|
+
# plain class rather than Data: one is made for every successful
|
|
10
|
+
# match, and Data.new allocates a keyword Hash each time.
|
|
11
|
+
class Match
|
|
12
|
+
attr_reader :pos, :value
|
|
13
|
+
|
|
14
|
+
def initialize(pos, value)
|
|
15
|
+
@pos = pos
|
|
16
|
+
@value = value
|
|
17
|
+
end
|
|
18
|
+
end
|
|
6
19
|
|
|
7
20
|
# Returned by Grammar#parse when the whole input parsed.
|
|
21
|
+
# [class.Success]
|
|
22
|
+
# purpose = "The whole input parsed"
|
|
23
|
+
# fields = ["value: whatever the root rule produced"]
|
|
24
|
+
# usage = "case grammar.parse(input) in Birman::Success(value) then ..."
|
|
8
25
|
Success = Data.define(:value) do
|
|
9
26
|
def success? = true
|
|
10
27
|
def failure? = false
|
|
@@ -19,6 +36,16 @@ module Birman
|
|
|
19
36
|
#
|
|
20
37
|
# reason: set when the failure isn't about what was expected, such as
|
|
21
38
|
# input nested too deeply for Ruby's stack.
|
|
39
|
+
# [class.Failure]
|
|
40
|
+
# purpose = "The input didn't parse"
|
|
41
|
+
# fields = [
|
|
42
|
+
# "input: String",
|
|
43
|
+
# "pos: Integer (character offset of the furthest failure)",
|
|
44
|
+
# "expected: Array of String (what would have matched there)",
|
|
45
|
+
# "committed: Boolean (failed after a commit)",
|
|
46
|
+
# "reason: String | nil (set when it isn't about expectations, e.g. nesting too deep)"
|
|
47
|
+
# ]
|
|
48
|
+
# methods = ["line", "column", "found", "message(formatter)", "value!(formatter) -> raises ParseError"]
|
|
22
49
|
Failure = Data.define(:input, :pos, :expected, :committed, :reason) do
|
|
23
50
|
def initialize(input:, pos:, expected:, committed:, reason: nil) = super
|
|
24
51
|
|
|
@@ -42,6 +69,9 @@ module Birman
|
|
|
42
69
|
end
|
|
43
70
|
|
|
44
71
|
# Raised by parse! (and Failure#value!) with the formatted message.
|
|
72
|
+
# [class.ParseError]
|
|
73
|
+
# purpose = "Raised by parse! and Failure#value!"
|
|
74
|
+
# fields = ["failure: Birman::Failure", "message: formatted by the formatter given"]
|
|
45
75
|
class ParseError < StandardError
|
|
46
76
|
attr_reader :failure
|
|
47
77
|
|
data/lib/birman/rule_set.rb
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# [file]
|
|
4
|
+
# purpose = "The rule and lexeme macros"
|
|
5
|
+
# responsibility = "Turning rule blocks into methods, and guarding rule names"
|
|
6
|
+
# pattern = "Class-level DSL (extend)"
|
|
3
7
|
module Birman
|
|
4
8
|
# The `rule` and `lexeme` macros. Extend any module or class with it.
|
|
5
9
|
#
|
|
@@ -7,7 +11,18 @@ module Birman
|
|
|
7
11
|
# private `__rule_name` builds the matcher. Because rules are just
|
|
8
12
|
# methods, they travel through `include` and can be overridden like
|
|
9
13
|
# any other Ruby method.
|
|
14
|
+
# [module.RuleSet]
|
|
15
|
+
# purpose = "Defines rules; extend a module with it to write a reusable rule module"
|
|
16
|
+
# collaborators = ["Grammar (extends its classes with this)", "Ref"]
|
|
10
17
|
module RuleSet
|
|
18
|
+
# [method.rule]
|
|
19
|
+
# params = ["name: Symbol", "block: returns the rule's matcher (String, Regexp or Matcher)"]
|
|
20
|
+
# returns = "nil"
|
|
21
|
+
# side_effects = [
|
|
22
|
+
# "Defines name (returns a Ref) and a private __rule_name",
|
|
23
|
+
# "Resets a grammar class's cached instance"
|
|
24
|
+
# ]
|
|
25
|
+
# raises = ["ArgumentError if name would replace a method the grammar relies on (send, match, token...)"]
|
|
11
26
|
def rule(name, &)
|
|
12
27
|
check_rule_name!(name)
|
|
13
28
|
define_method(:"__rule_#{name}", &)
|
|
@@ -18,6 +33,10 @@ module Birman
|
|
|
18
33
|
|
|
19
34
|
# A rule for a token (a number, a string, an identifier): it skips
|
|
20
35
|
# trailing spacing when the grammar declares `ignore`.
|
|
36
|
+
# [method.lexeme]
|
|
37
|
+
# params = ["name: Symbol", "body: block returning the token's matcher"]
|
|
38
|
+
# returns = "nil"
|
|
39
|
+
# side_effects = ["Defines a rule that skips trailing spacing via the grammar's ignore rule"]
|
|
21
40
|
def lexeme(name, &body)
|
|
22
41
|
rule(name) { Lexeme.new(Birman.coerce(instance_exec(&body)), ignore_rule) }
|
|
23
42
|
end
|
data/lib/birman/rules.rb
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# [file]
|
|
4
|
+
# purpose = "Ready-made rule modules"
|
|
5
|
+
# responsibility = "Whitespace, comments, numbers, strings and identifiers"
|
|
6
|
+
# usage = "include the modules a grammar needs; later includes override earlier ones"
|
|
3
7
|
module Birman
|
|
4
8
|
# A standard library of rule modules. Include the ones you need;
|
|
5
9
|
# later includes override earlier ones (Comments replaces
|
|
@@ -10,6 +14,9 @@ module Birman
|
|
|
10
14
|
module Rules
|
|
11
15
|
# Whitespace as the `spacing` rule, plus a `token` helper for grammars
|
|
12
16
|
# that don't declare `ignore`.
|
|
17
|
+
# [module.Rules.Whitespace]
|
|
18
|
+
# rules = ["spacing: zero or more whitespace characters (quiet)"]
|
|
19
|
+
# helpers = ["token(m): m then spacing, for grammars without ignore"]
|
|
13
20
|
module Whitespace
|
|
14
21
|
extend RuleSet
|
|
15
22
|
|
|
@@ -22,6 +29,9 @@ module Birman
|
|
|
22
29
|
|
|
23
30
|
# Whitespace including comments. Works on its own or after
|
|
24
31
|
# Whitespace. Override comment_pattern for other comment syntaxes.
|
|
32
|
+
# [module.Rules.Comments]
|
|
33
|
+
# rules = ["spacing: whitespace and comments (quiet)", "comment: one comment"]
|
|
34
|
+
# helpers = ["comment_pattern: override for other syntaxes (default /#[^\\n]*/)", "token(m)"]
|
|
25
35
|
module Comments
|
|
26
36
|
extend RuleSet
|
|
27
37
|
|
|
@@ -33,6 +43,8 @@ module Birman
|
|
|
33
43
|
end
|
|
34
44
|
|
|
35
45
|
# Integers and floats, as Ruby numbers.
|
|
46
|
+
# [module.Rules.Numbers]
|
|
47
|
+
# rules = ["integer: Integer", "float: Float", "number: float or integer"]
|
|
36
48
|
module Numbers
|
|
37
49
|
extend RuleSet
|
|
38
50
|
|
|
@@ -43,6 +55,9 @@ module Birman
|
|
|
43
55
|
|
|
44
56
|
# Double-quoted strings with backslash escapes. The opening quote
|
|
45
57
|
# commits, so an unterminated string is reported where it breaks.
|
|
58
|
+
# [module.Rules.Strings]
|
|
59
|
+
# rules = ["string: double-quoted, escapes \\n \\t \\r \\0, value is the String"]
|
|
60
|
+
# notes = "The opening quote commits, so an unterminated string is reported where it breaks"
|
|
46
61
|
module Strings
|
|
47
62
|
extend RuleSet
|
|
48
63
|
|
|
@@ -62,6 +77,8 @@ module Birman
|
|
|
62
77
|
|
|
63
78
|
# Identifiers that can't be reserved words. Declare those in the
|
|
64
79
|
# grammar with `keywords "if", "then", ...`.
|
|
80
|
+
# [module.Rules.Identifiers]
|
|
81
|
+
# rules = ["identifier: [A-Za-z_]\\w*, not one of the grammar's keywords"]
|
|
65
82
|
module Identifiers
|
|
66
83
|
extend RuleSet
|
|
67
84
|
|
data/lib/birman/version.rb
CHANGED
data/lib/birman.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# [file]
|
|
4
|
+
# purpose = "Entry point: require \"birman\" loads the whole library"
|
|
5
|
+
# dependencies = ["strscan (standard library)"]
|
|
6
|
+
# requires_ruby = ">= 3.2"
|
|
7
|
+
|
|
3
8
|
# A small PEG parser library for Ruby 3.2+.
|
|
4
9
|
#
|
|
5
10
|
# Greeting = Birman.node(:name)
|
|
@@ -22,6 +27,9 @@ module Birman
|
|
|
22
27
|
# The character offset of a byte offset into input. Free for ASCII
|
|
23
28
|
# input (Ruby caches ascii_only?), linear otherwise; used once per
|
|
24
29
|
# parse for a Failure, and only on demand for a Span.
|
|
30
|
+
# [method."Birman.char_offset"]
|
|
31
|
+
# params = ["input: String", "byte_pos: Integer"]
|
|
32
|
+
# returns = "Integer: the character offset of byte_pos"
|
|
25
33
|
def self.char_offset(input, byte_pos)
|
|
26
34
|
input.ascii_only? ? byte_pos : input.byteslice(0, byte_pos).length
|
|
27
35
|
end
|