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 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