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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e56dfecb27c1584f8aefdb873503084e140abf83d53f2b85ed453ad1e25dbbe
4
- data.tar.gz: 35b4ee8725298c3809fbde953f51fca4c550ac207a68a2eade43ad7745e02b15
3
+ metadata.gz: 9b052c872052f6d727f345e06626e7a11f080717c04474bb6bfdcd3a2ad9464a
4
+ data.tar.gz: 0daa868d84a98399a7ed835301ae320962d9867b949aa83e1dc830e1322fab7c
5
5
  SHA512:
6
- metadata.gz: abf8ca7031fe77792fb4a3e0a2c8480358320b2edfeebe0e8da7de0814cf71cbccc5229cf8fb710ec1ddebd49208a9b6f7cf8d9a3b6a066830b57b7271e90827
7
- data.tar.gz: bdf4b37b3adcfc42ce7f5eb1bee447c83bbe5cc25ab4795a6478991775f9d913554426abf5c6b1392314f072fced9e7f53b8615ffb4fe5eeb37d0483764ece40
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.1"
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 (several hundred levels)
169
- is a `Failure` too, with `reason` set.
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
@@ -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
- def str(s) = Str.new(s)
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
- def any = Pat.new(/./m)
24
- def eof = any.absent.label("end of input")
25
- def never = Pat.new(/(?!)/)
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
@@ -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, :memo, :furthest, :expected, :position
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. Returns [result, furthest, expected] so
58
- # the caller decides what to keep: Ref memoizes it, Label rewrites
59
- # it, Not throws it away.
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
- outer = [@furthest, @expected]
66
+ outer_furthest = @furthest
67
+ outer_expected = @expected
62
68
  @furthest = pos
63
- @expected = []
69
+ @expected = NOTHING_EXPECTED
64
70
  result = yield
65
- [result, @furthest, @expected]
71
+ @isolated_furthest = @furthest
72
+ @isolated_expected = @expected
73
+ result
66
74
  ensure
67
- @furthest, @expected = outer
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, furthest, expected = isolate(pos) { lookahead { matcher.match(self, pos) } }
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
- key = [name, pos]
95
- entry = @memo[key]
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(key, &)
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
- def compute(key)
115
- @memo[key] = IN_PROGRESS
116
- @memo[key] = yield
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
- @memo.delete(key) if @memo[key].equal?(IN_PROGRESS)
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
@@ -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
- def parse(input) = instance.parse(input)
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
- def to_ast = instance.to_ast
53
- def to_ebnf = EBNF.call(to_ast)
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)
@@ -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
- def |(other) = Choice.new([self, Birman.coerce(other)])
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
- def maybe = rep(0, 1).map(&:first)
44
- def absent = Not.new(self)
45
- def present = And.new(self)
46
- def map(&fn) = Action.new(self, fn)
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
- if ctx.input.byteslice(pos, str.bytesize) == str
86
- Match.new(pos + str.bytesize, str)
87
- else
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.each_with_index.map do |part, i|
130
- r = part.match(ctx, pos)
131
- unless r
132
- fail_committed!(ctx, part, pos) if commit_at && i >= commit_at
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, = ctx.isolate(pos) { ctx.lookahead { inner.match(ctx, pos) } }
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.with(value: fn.call(r.value))
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, furthest, expected = ctx.isolate(pos) { inner.match(ctx, pos) }
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.with(value: Birman.build(node, fields, ctx, pos, r.pos))
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) = super
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 = Ref.new(ignore).match(ctx, r.pos) or return r
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
- result, = ctx.isolate(pos) { inner.match(ctx, pos) }
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
- Match = Data.define(:pos, :value)
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
 
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Birman
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.2"
5
5
  end
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: birman
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tad Thorley