birman 0.1.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 +7 -0
- data/CHANGELOG.md +42 -0
- data/LICENSE.txt +21 -0
- data/README.md +193 -0
- data/lib/birman/ast.rb +80 -0
- data/lib/birman/combinators.rb +75 -0
- data/lib/birman/context.rb +123 -0
- data/lib/birman/error_formatter.rb +38 -0
- data/lib/birman/grammar.rb +122 -0
- data/lib/birman/matchers.rb +396 -0
- data/lib/birman/nodes.rb +90 -0
- data/lib/birman/result.rb +65 -0
- data/lib/birman/rule_set.rb +39 -0
- data/lib/birman/rules.rb +77 -0
- data/lib/birman/version.rb +5 -0
- data/lib/birman.rb +42 -0
- metadata +60 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b986fd1949d2c3755859b6acd8753027417e40a382e18a0bdd02e350133bb1fa
|
|
4
|
+
data.tar.gz: cd8f04b2b86f234cf915b7b1ad941574f03bfe4f2f1f35fd6488bc94ec27911a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 430c9e5d8b26483919f9eb0caaf24c39614752b117e4e61b45faa2919598453a75558a6e928d592055b765563a5fe1a531022ce4311687cc3689bdc6c3f961b2
|
|
7
|
+
data.tar.gz: 7856edc44f834226c32f014a467de9d89fd5f4e4e840475f3c14b021199ae6de4b62b59520fc5eb9d813e17eb4e521e1e64fc3d74c7db1b49ea983b4ed090d1c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-10-17
|
|
4
|
+
|
|
5
|
+
First release: a small PEG parser library for Ruby 3.2+.
|
|
6
|
+
|
|
7
|
+
- **Grammars** are classes that `include Birman::Grammar`. Rules are
|
|
8
|
+
methods (`rule(:name) { ... }`), so they compose and override through
|
|
9
|
+
`include`, and a module that includes `Birman::Grammar` can share
|
|
10
|
+
rules between grammars. Settings: `root`, `ignore` (a rule matched and
|
|
11
|
+
discarded between tokens, usually `:spacing`) and `keywords`, all
|
|
12
|
+
inherited and clearable with `nil`.
|
|
13
|
+
- **Combinators**: `seq`, `either`, `one_of`, `maybe`, `rep`,
|
|
14
|
+
`absent`/`present`, `&` guards, `sep_by`, `between`, `parens`,
|
|
15
|
+
left-associative `infix`, `label`, `quiet`, `map`, plus operator forms
|
|
16
|
+
(`>>`, `|`, `&`, `>`). Bare strings in `seq`/`either` are literals that
|
|
17
|
+
skip trailing spacing; word-like ones are keywords.
|
|
18
|
+
- **Captures and nodes**: `.as(:key)` names parts of a sequence and
|
|
19
|
+
`.to(Node)` builds a node from them. `node(:a, :b)` defines immutable
|
|
20
|
+
`Data` AST nodes with a source `span` (line, column, text), which
|
|
21
|
+
equality ignores; nodes may define their own `initialize`.
|
|
22
|
+
- **Commit points**: `seq("let", commit, ...)` (or `a > b`) makes a
|
|
23
|
+
failure after the commit end the parse with an error at that spot.
|
|
24
|
+
Lookaheads contain commits; `rep` and `maybe` don't.
|
|
25
|
+
- **Packrat memoization**, with failure diagnostics replayed from the
|
|
26
|
+
memo so cached results report errors correctly. Linear-time matching
|
|
27
|
+
on non-ASCII input (byte offsets internally, character offsets in the
|
|
28
|
+
API).
|
|
29
|
+
- **Results**: `parse` returns `Success`/`Failure` and doesn't raise on
|
|
30
|
+
bad input, including input nested too deeply for Ruby's stack;
|
|
31
|
+
`parse!` raises `ParseError`. Failures carry position, expectations
|
|
32
|
+
and a pluggable formatter (`Failure#message(formatter)`,
|
|
33
|
+
`parse!(input, formatter:)`).
|
|
34
|
+
- **Grammar mistakes fail loudly**: left recursion raises naming the
|
|
35
|
+
rule, as do rule names that would replace a method the grammar relies
|
|
36
|
+
on (`send`, `match`, `token`, ...) and a `commit` at the start of a
|
|
37
|
+
`seq`.
|
|
38
|
+
- **Built-in rules**: `Birman::Rules::Whitespace`, `Comments`,
|
|
39
|
+
`Numbers`, `Strings` and `Identifiers`.
|
|
40
|
+
- **Introspection**: `Grammar.to_ast` and `Grammar.to_ebnf`.
|
|
41
|
+
- `examples/calc.rb`: a small language with an interpreter, as a
|
|
42
|
+
complete worked example.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TODO: Write your name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/birman.png" alt="Birman logo: a Birman cat's face" width="200">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Birman
|
|
6
|
+
|
|
7
|
+
A small PEG parser library for Ruby 3.2+: module-based grammars,
|
|
8
|
+
immutable `Data` matchers and AST nodes with source spans, packrat
|
|
9
|
+
memoization, commit points, labels, and `Success`/`Failure` results.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
bundle add birman
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "birman"
|
|
19
|
+
|
|
20
|
+
module Lang
|
|
21
|
+
# A grammar is a class that includes Birman::Grammar. Each rule is a
|
|
22
|
+
# method on it, so rules can call each other by name.
|
|
23
|
+
class Grammar
|
|
24
|
+
include Birman::Grammar
|
|
25
|
+
|
|
26
|
+
# Ready-made rules: `spacing` (whitespace), `number` and `identifier`.
|
|
27
|
+
include Birman::Rules::Whitespace
|
|
28
|
+
include Birman::Rules::Numbers
|
|
29
|
+
include Birman::Rules::Identifiers
|
|
30
|
+
|
|
31
|
+
# AST nodes: immutable Data classes that also record where in the
|
|
32
|
+
# input they came from (node.span).
|
|
33
|
+
Let = node(:name, :value)
|
|
34
|
+
Num = node(:value)
|
|
35
|
+
|
|
36
|
+
# Parsing starts at this rule.
|
|
37
|
+
root :let_statement
|
|
38
|
+
|
|
39
|
+
# Match `spacing` after every literal and token, and throw it away,
|
|
40
|
+
# so the rules below never have to mention whitespace.
|
|
41
|
+
ignore :spacing
|
|
42
|
+
|
|
43
|
+
# Reserved words: `identifier` won't accept "let" as a name.
|
|
44
|
+
keywords "let"
|
|
45
|
+
|
|
46
|
+
# let_statement: "let", a name, "=", a number. Reading left to right:
|
|
47
|
+
# "let" - a literal keyword ("let", not "letter")
|
|
48
|
+
# commit - after "let", this must be a let statement;
|
|
49
|
+
# a mistake later is reported right there
|
|
50
|
+
# identifier.as(:name) - match a name and save it as :name
|
|
51
|
+
# "=" - another literal
|
|
52
|
+
# number.to(Num) - match a number and wrap it in a Num node,
|
|
53
|
+
# .as(:value) saved as :value
|
|
54
|
+
# .to(Let) - build a Let from the saved :name and :value
|
|
55
|
+
rule(:let_statement) do
|
|
56
|
+
seq("let", commit, identifier.as(:name), "=", number.to(Num).as(:value)).to(Let)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# parse returns Success or Failure (bad input never raises), so
|
|
62
|
+
# pattern-match on the result.
|
|
63
|
+
# (parse! returns the value or raises Birman::ParseError instead.)
|
|
64
|
+
case Lang::Grammar.parse("let x = 42")
|
|
65
|
+
in Birman::Success(ast) then p ast # => (Let "x" (Num 42))
|
|
66
|
+
in Birman::Failure => f then warn f.message
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
See `examples/calc.rb` for a complete small language with an
|
|
71
|
+
interpreter, and `Grammar.to_ebnf` to print any grammar.
|
|
72
|
+
|
|
73
|
+
## Building blocks
|
|
74
|
+
|
|
75
|
+
| Form | Meaning |
|
|
76
|
+
|-------------------------------|------------------------------------------------------|
|
|
77
|
+
| `seq(a, b, c)` | sequence |
|
|
78
|
+
| `seq(a, commit, b, c)` | after `a`, the rest must match or parsing stops here |
|
|
79
|
+
| `either(a, b, c)` | ordered choice |
|
|
80
|
+
| `x.as(:key)` | name a part; the sequence's value becomes a hash |
|
|
81
|
+
| `x.to(Node)` | build a node from that hash (or from a single value) |
|
|
82
|
+
| `infix(operand, "+", "-", node: BinOp)` | left-associative binary operators; `BinOp` needs `op`, `left`, `right` fields |
|
|
83
|
+
| `parens(x)`, `between(o, c, x)` | wrapped |
|
|
84
|
+
| `sep_by(x, ",")`, `sep_by1` | separated lists |
|
|
85
|
+
| `.rep(min, max)`, `.maybe` | repetition, optional |
|
|
86
|
+
| `.absent`, `.present` | negative / positive lookahead |
|
|
87
|
+
| `.map { }` | transform the value |
|
|
88
|
+
| `.label("name")` | "expected name" in errors |
|
|
89
|
+
| `.quiet` | never mention in errors |
|
|
90
|
+
|
|
91
|
+
The operator forms still work for short rules: `a >> b` (sequence),
|
|
92
|
+
`a | b` (choice), `a & b` (guard), `a > b` (commit). Precedence, tightest
|
|
93
|
+
first: `>>`, `&`, `|`, `>`.
|
|
94
|
+
|
|
95
|
+
## Literals, spacing, and lexemes
|
|
96
|
+
|
|
97
|
+
Declare `ignore :spacing` and whitespace (and comments, with
|
|
98
|
+
`Rules::Comments`) takes care of itself:
|
|
99
|
+
|
|
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`.
|
|
103
|
+
- `str("...")` and `match(/.../)` are **raw**: exactly those characters,
|
|
104
|
+
no skipping.
|
|
105
|
+
- Rules for tokens (numbers, names, strings) should be declared with
|
|
106
|
+
**`lexeme`** instead of `rule`, so they skip trailing spacing too. The
|
|
107
|
+
built-in `Birman::Rules` already do this.
|
|
108
|
+
|
|
109
|
+
## Commit
|
|
110
|
+
|
|
111
|
+
`seq("let", commit, name, "=", expr)` says: once `"let"` has matched,
|
|
112
|
+
this is definitely a let statement. If anything after `commit` fails,
|
|
113
|
+
the whole parse fails right there, with that error. No enclosing
|
|
114
|
+
`either`, `rep` or `maybe` gets to backtrack and try something else.
|
|
115
|
+
That reach is what keeps errors pointing at the real problem instead
|
|
116
|
+
of at whatever a looser alternative accepted.
|
|
117
|
+
|
|
118
|
+
- `rep` and `maybe` don't stop a commit. `seq("else", commit, expr).maybe`
|
|
119
|
+
rejects `else` followed by garbage rather than quietly treating it as
|
|
120
|
+
"no else clause".
|
|
121
|
+
- Lookaheads do. `x.absent`, `x.present` and the guard in `a & b` only
|
|
122
|
+
ask whether `x` would match, so a commit failure inside them is just
|
|
123
|
+
"no".
|
|
124
|
+
- `commit` can't come first in a `seq`. Nothing has matched yet, so
|
|
125
|
+
there's nothing to be sure about.
|
|
126
|
+
|
|
127
|
+
This is Parsec-style commitment, not the choice-local "cut" from the
|
|
128
|
+
PEG literature.
|
|
129
|
+
|
|
130
|
+
## Nodes
|
|
131
|
+
|
|
132
|
+
`node(:a, :b)` (in any grammar class, from `extend Birman::Nodes`, or
|
|
133
|
+
as `Birman.node`) is `Data.define` plus:
|
|
134
|
+
|
|
135
|
+
- a `span` the parser fills in (`node.span.line`, `.column`, `.text`),
|
|
136
|
+
which equality and pattern matching ignore
|
|
137
|
+
- s-expression `inspect`: `(BinOp "+" (Num 1) (Num 2))`
|
|
138
|
+
|
|
139
|
+
A node can define its own `initialize`, for example to give a field a
|
|
140
|
+
default: `node(:name, :args) { def initialize(name:, args: []) = super }`.
|
|
141
|
+
The parser and `with` set the span without going through it. (Passing
|
|
142
|
+
`span:` to `new` by hand only works for nodes without their own
|
|
143
|
+
`initialize`.) `span` can't be a field name, since every node already
|
|
144
|
+
has one.
|
|
145
|
+
|
|
146
|
+
## Pieces
|
|
147
|
+
|
|
148
|
+
- `Birman::Rules`: `Whitespace`, `Comments`, `Numbers`, `Strings`,
|
|
149
|
+
`Identifiers`
|
|
150
|
+
- Grammar settings: `root`, `ignore`, `keywords`. Subclasses inherit
|
|
151
|
+
them; pass `nil` to clear one (`ignore nil`, `keywords nil`)
|
|
152
|
+
- `Grammar.parse` returns `Success`/`Failure`; `parse!` raises `ParseError`
|
|
153
|
+
(`parse!(input, formatter: f)` words it your way).
|
|
154
|
+
Input nested too deeply for Ruby's stack (several hundred levels)
|
|
155
|
+
is a `Failure` too, with `reason` set.
|
|
156
|
+
- `Failure#message(formatter)` takes anything with `#call(failure)`
|
|
157
|
+
- `Grammar.to_ast` and `Grammar.to_ebnf` for introspection
|
|
158
|
+
- Rules are methods, so they compose and override through `include`.
|
|
159
|
+
Write your own rule module with `extend Birman::RuleSet`, or
|
|
160
|
+
`include Birman::Grammar` in a module to share rules, nodes and
|
|
161
|
+
combinators; any class that includes it is a grammar. Settings
|
|
162
|
+
(`root`, `ignore`, `keywords`) still go on the class.
|
|
163
|
+
- A rule can't be named after a method the grammar relies on (`send`,
|
|
164
|
+
`class`, `match`, `token`...). Defining one raises `ArgumentError`
|
|
165
|
+
instead of silently breaking the grammar. Redefining a rule is fine.
|
|
166
|
+
|
|
167
|
+
## Not yet
|
|
168
|
+
|
|
169
|
+
- Left recursion. A rule that can reach itself without consuming input
|
|
170
|
+
raises `ArgumentError` naming the rule; use `infix`, or
|
|
171
|
+
`term (op term)*` with a fold, instead.
|
|
172
|
+
- Compiling grammars to Ruby methods for speed
|
|
173
|
+
- Railroad diagrams from `to_ast`
|
|
174
|
+
|
|
175
|
+
## Development
|
|
176
|
+
|
|
177
|
+
```sh
|
|
178
|
+
bin/setup # install dependencies
|
|
179
|
+
bundle exec rake # tests + rubocop
|
|
180
|
+
ruby examples/calc.rb
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
|
186
|
+
|
|
187
|
+
## The name
|
|
188
|
+
|
|
189
|
+
Birman is named for Alexander Birman, whose 1970 Ph.D. thesis, *The
|
|
190
|
+
TMG Recognition Schema*, was the origin of packrat parsing and parsing
|
|
191
|
+
expression grammars.
|
|
192
|
+
|
|
193
|
+
The cat in the logo is a Birman too.
|
data/lib/birman/ast.rb
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Birman
|
|
4
|
+
# Helpers for the plain-array ASTs that matchers produce via #to_ast:
|
|
5
|
+
#
|
|
6
|
+
# [:str, "if"] [:pat, "/\\d+/"] [:ref, :expr]
|
|
7
|
+
# [:seq, commit_at, [parts...]] [:choice, [alts...]]
|
|
8
|
+
# [:rep, min, max, inner] [:not, inner] [:and, inner]
|
|
9
|
+
# [:both, left, right] [:action, inner] [:label, name, inner]
|
|
10
|
+
# [:quiet, inner]
|
|
11
|
+
module AST
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Rule names referenced in an AST, in first-use order.
|
|
15
|
+
def refs(node)
|
|
16
|
+
case node
|
|
17
|
+
in [:ref, name] then [name]
|
|
18
|
+
in Array then node.flat_map { refs(_1) }.uniq
|
|
19
|
+
else []
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Prints a grammar AST in PEG-flavored EBNF:
|
|
25
|
+
#
|
|
26
|
+
# a b sequence a / b choice ~ commit (rest must match)
|
|
27
|
+
# a* a+ a? a{2,3} !a not &a lookahead / guard
|
|
28
|
+
#
|
|
29
|
+
# Actions and labels don't change what's accepted, so they're omitted.
|
|
30
|
+
module EBNF
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# Precedence levels, loosest to tightest.
|
|
34
|
+
CHOICE = 0
|
|
35
|
+
SEQ = 1
|
|
36
|
+
PREFIX = 2
|
|
37
|
+
ATOM = 3
|
|
38
|
+
|
|
39
|
+
def call(rules)
|
|
40
|
+
width = rules.keys.map { _1.to_s.size }.max
|
|
41
|
+
rules.map { |name, ast| "#{name.to_s.ljust(width)} = #{expr(ast, CHOICE)} ;" }.join("\n")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# One branch per AST node type, so its size grows with the matcher set.
|
|
45
|
+
def expr(node, prec) # rubocop:disable Metrics
|
|
46
|
+
case node
|
|
47
|
+
in [:str, s] then s.inspect
|
|
48
|
+
in [:pat, source] then source
|
|
49
|
+
in [:ref, name] then name.to_s
|
|
50
|
+
in [:action, inner] then expr(inner, prec)
|
|
51
|
+
in [:label, _, inner] then expr(inner, prec)
|
|
52
|
+
in [:quiet, inner] then expr(inner, prec)
|
|
53
|
+
in [:both, [:not, guard], right]
|
|
54
|
+
wrap("!#{expr(guard, ATOM)} #{expr(right, PREFIX)}", prec > SEQ)
|
|
55
|
+
in [:choice, alts] then wrap(alts.map { expr(_1, SEQ) }.join(" / "), prec > CHOICE)
|
|
56
|
+
in [:seq, commit_at, parts]
|
|
57
|
+
items = parts.map { expr(_1, PREFIX) }
|
|
58
|
+
items.insert(commit_at, "~") if commit_at
|
|
59
|
+
wrap(items.join(" "), prec > SEQ)
|
|
60
|
+
in [:both, left, right] then wrap("&#{expr(left, ATOM)} #{expr(right, PREFIX)}", prec > SEQ)
|
|
61
|
+
in [:not, inner] then wrap("!#{expr(inner, ATOM)}", prec > PREFIX)
|
|
62
|
+
in [:and, inner] then wrap("&#{expr(inner, ATOM)}", prec > PREFIX)
|
|
63
|
+
in [:rep, min, max, inner] then "#{expr(inner, ATOM)}#{suffix(min, max)}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def suffix(min, max)
|
|
68
|
+
case [min, max]
|
|
69
|
+
in [0, nil] then "*"
|
|
70
|
+
in [1, nil] then "+"
|
|
71
|
+
in [0, 1] then "?"
|
|
72
|
+
in [n, nil] then "{#{n},}"
|
|
73
|
+
in [n, ^n] then "{#{n}}"
|
|
74
|
+
in [m, n] then "{#{m},#{n}}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def wrap(text, parens) = parens ? "(#{text})" : text
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Birman
|
|
4
|
+
# Marker for seq: everything after it is committed (see Matcher#>).
|
|
5
|
+
COMMIT = Object.new.tap { |o| def o.inspect = "Birman::COMMIT" }.freeze
|
|
6
|
+
|
|
7
|
+
# Building blocks and parameterized rule factories, available in
|
|
8
|
+
# every grammar. They're plain methods, so a module can override
|
|
9
|
+
# them or add more.
|
|
10
|
+
#
|
|
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.
|
|
15
|
+
module Combinators
|
|
16
|
+
# --- Raw matchers ---
|
|
17
|
+
|
|
18
|
+
def str(s) = Str.new(s)
|
|
19
|
+
def match(re) = Pat.new(re)
|
|
20
|
+
def any = Pat.new(/./m)
|
|
21
|
+
def eof = any.absent.label("end of input")
|
|
22
|
+
def never = Pat.new(/(?!)/)
|
|
23
|
+
|
|
24
|
+
# A whole word: "if" matches in "if x" but not in "iffy".
|
|
25
|
+
def keyword(word) = (str(word) >> match(/\w/).absent).map(&:first).label(word.inspect)
|
|
26
|
+
|
|
27
|
+
# --- Literals and the readable forms ---
|
|
28
|
+
|
|
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)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Sequence. Put `commit` where the construct becomes unambiguous:
|
|
40
|
+
# seq("let", commit, name.as(:name), "=", expr.as(:value))
|
|
41
|
+
def seq(*items)
|
|
42
|
+
commit_at = items.index(COMMIT)
|
|
43
|
+
raise ArgumentError, "commit can't come first in a seq: nothing has matched yet" if commit_at&.zero?
|
|
44
|
+
|
|
45
|
+
parts = items.reject { _1.equal?(COMMIT) }.map { lit(_1) }
|
|
46
|
+
Seq.new(parts, commit_at)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def commit = COMMIT
|
|
50
|
+
|
|
51
|
+
# Ordered choice: the first alternative that matches wins.
|
|
52
|
+
def either(*alts) = alts.size == 1 ? lit(alts.first) : Choice.new(alts.map { lit(_1) })
|
|
53
|
+
|
|
54
|
+
def one_of(*strings) = either(*strings)
|
|
55
|
+
|
|
56
|
+
# Left-associative binary operators, folded into nodes:
|
|
57
|
+
# infix(product, "+", "-", node: BinOp) # BinOp = node(:op, :left, :right)
|
|
58
|
+
def infix(operand, *ops, node:) = Infix.new(Birman.coerce(operand), either(*ops).label("operator"), node)
|
|
59
|
+
|
|
60
|
+
# One or more items separated by sep; value is an array of items.
|
|
61
|
+
def sep_by1(item, sep) = SepBy.new(lit(item), lit(sep))
|
|
62
|
+
|
|
63
|
+
# Zero or more items separated by sep.
|
|
64
|
+
def sep_by(item, sep) = sep_by1(item, sep).maybe.map { _1 || [] }
|
|
65
|
+
|
|
66
|
+
# inner wrapped in open/close; value is inner's.
|
|
67
|
+
def between(open, close, inner) = Pick.new([lit(open), Birman.coerce(inner), lit(close)], 1)
|
|
68
|
+
|
|
69
|
+
def parens(inner) = between("(", ")", inner)
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def ignore_rule = self.class.respond_to?(:ignore) ? self.class.ignore : nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Birman
|
|
4
|
+
# Per-parse state: input, memo table, and failure tracking. It
|
|
5
|
+
# mutates constantly, so it's a plain class rather than Data.
|
|
6
|
+
#
|
|
7
|
+
# Positions here, and in every matcher, are byte offsets: slicing a
|
|
8
|
+
# non-ASCII string by character offset costs time proportional to
|
|
9
|
+
# the offset, which made parsing quadratic. Failure and Span convert
|
|
10
|
+
# back to characters for anything public.
|
|
11
|
+
class Context
|
|
12
|
+
# Marks a memo entry whose rule is still being matched.
|
|
13
|
+
IN_PROGRESS = Object.new.freeze
|
|
14
|
+
|
|
15
|
+
# position: where the most recently entered rule started. When the
|
|
16
|
+
# input nests too deeply for Ruby's stack, that's where it happened.
|
|
17
|
+
attr_reader :input, :grammar, :memo, :furthest, :expected, :position
|
|
18
|
+
|
|
19
|
+
def initialize(input, grammar)
|
|
20
|
+
@input = input
|
|
21
|
+
@grammar = grammar
|
|
22
|
+
@memo = {}
|
|
23
|
+
@furthest = 0
|
|
24
|
+
@expected = []
|
|
25
|
+
@token_ends = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A lexeme ended at token_end, then the ignore rule consumed spacing
|
|
29
|
+
# (whitespace, comments) up to pos. Spans use this to stop at the
|
|
30
|
+
# token instead of including what was ignored after it.
|
|
31
|
+
def ignored_after(token_end, pos)
|
|
32
|
+
@token_ends[pos] = token_end if pos > token_end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Where the last token ended, for a match that ended at pos.
|
|
36
|
+
def token_end(pos) = @token_ends.fetch(pos, pos)
|
|
37
|
+
|
|
38
|
+
# fixed_anchor: \A, ^ and lookbehind see the whole input, not just
|
|
39
|
+
# the text from the current position on.
|
|
40
|
+
def scanner = @scanner ||= StringScanner.new(input, fixed_anchor: true)
|
|
41
|
+
|
|
42
|
+
# Record that something was expected at pos. Only the furthest
|
|
43
|
+
# position matters: that's where the parser got stuck.
|
|
44
|
+
def fail_at(pos, what)
|
|
45
|
+
if pos > @furthest
|
|
46
|
+
@furthest = pos
|
|
47
|
+
@expected = [what]
|
|
48
|
+
elsif pos == @furthest
|
|
49
|
+
@expected << what
|
|
50
|
+
end
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def merge(pos, expected) = expected.each { fail_at(pos, _1) }
|
|
55
|
+
|
|
56
|
+
# Run the block with fresh failure tracking starting at pos, then
|
|
57
|
+
# restore the outer state. Returns [result, furthest, expected] so
|
|
58
|
+
# the caller decides what to keep: Ref memoizes it, Label rewrites
|
|
59
|
+
# it, Not throws it away.
|
|
60
|
+
def isolate(pos)
|
|
61
|
+
outer = [@furthest, @expected]
|
|
62
|
+
@furthest = pos
|
|
63
|
+
@expected = []
|
|
64
|
+
result = yield
|
|
65
|
+
[result, @furthest, @expected]
|
|
66
|
+
ensure
|
|
67
|
+
@furthest, @expected = outer
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def commit_failure! = raise(Committed.new(@furthest, @expected.dup))
|
|
71
|
+
|
|
72
|
+
# A positive lookahead: does matcher match at pos? Nothing is
|
|
73
|
+
# consumed, and failures inside it describe input the parse never
|
|
74
|
+
# moves past, so they're dropped. If it fails, that's recorded at
|
|
75
|
+
# pos, as what it expected there or as the lookahead itself.
|
|
76
|
+
def peek?(matcher, pos)
|
|
77
|
+
result, furthest, expected = isolate(pos) { lookahead { matcher.match(self, pos) } }
|
|
78
|
+
return true if result
|
|
79
|
+
|
|
80
|
+
if furthest == pos && expected.any?
|
|
81
|
+
merge(pos, expected)
|
|
82
|
+
else
|
|
83
|
+
fail_at(pos, EBNF.expr(matcher.to_ast, EBNF::CHOICE))
|
|
84
|
+
end
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Rule name's memo entry at pos, computed by the block the first
|
|
89
|
+
# time. Meeting the rule again at the same position while it is
|
|
90
|
+
# still being matched means it reaches itself without consuming
|
|
91
|
+
# input: left recursion, which would never terminate. That's a bug
|
|
92
|
+
# in the grammar, not the input, so it raises.
|
|
93
|
+
def memoize(name, pos, &)
|
|
94
|
+
key = [name, pos]
|
|
95
|
+
entry = @memo[key]
|
|
96
|
+
return entry if entry && !entry.equal?(IN_PROGRESS)
|
|
97
|
+
raise ArgumentError, "left recursion in rule :#{name}: it can reach itself without consuming input" if entry
|
|
98
|
+
|
|
99
|
+
@position = pos
|
|
100
|
+
compute(key, &)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Run a lookahead's inner match. A lookahead only asks whether
|
|
104
|
+
# something would match here, so a commit failure inside it is an
|
|
105
|
+
# ordinary failure (nil) rather than the end of the parse.
|
|
106
|
+
def lookahead
|
|
107
|
+
yield
|
|
108
|
+
rescue Committed
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def compute(key)
|
|
115
|
+
@memo[key] = IN_PROGRESS
|
|
116
|
+
@memo[key] = yield
|
|
117
|
+
ensure
|
|
118
|
+
# A commit failure caught by a lookahead unwinds through here; the
|
|
119
|
+
# rule may be tried again at this position afterwards.
|
|
120
|
+
@memo.delete(key) if @memo[key].equal?(IN_PROGRESS)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Birman
|
|
4
|
+
# Turns a Failure into readable text. Anything that responds to
|
|
5
|
+
# #call(failure) works in its place, for custom wording or i18n:
|
|
6
|
+
#
|
|
7
|
+
# failure.message(MyFormatter.new)
|
|
8
|
+
class ErrorFormatter
|
|
9
|
+
def call(failure)
|
|
10
|
+
source = source_line(failure)
|
|
11
|
+
<<~MSG.chomp
|
|
12
|
+
line #{failure.line}, column #{failure.column}: #{problem(failure)}
|
|
13
|
+
#{source}
|
|
14
|
+
#{caret_indent(source, failure.column)}^
|
|
15
|
+
MSG
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def problem(failure)
|
|
21
|
+
failure.reason || "expected #{expected_list(failure.expected)}, found #{failure.found}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def expected_list(items)
|
|
25
|
+
case items
|
|
26
|
+
in [] then "nothing"
|
|
27
|
+
in [only] then only
|
|
28
|
+
in [*rest, last] then "#{rest.join(", ")} or #{last}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Spaces up to the column, except tabs stay tabs, so the caret lands
|
|
33
|
+
# under the right character however wide the terminal draws a tab.
|
|
34
|
+
def caret_indent(source, column) = source[0, column - 1].to_s.gsub(/[^\t]/, " ").ljust(column - 1)
|
|
35
|
+
|
|
36
|
+
def source_line(failure) = failure.input.lines.fetch(failure.line - 1, "").chomp
|
|
37
|
+
end
|
|
38
|
+
end
|