Skip to content

Commit cba604e

Browse files
gh-153568: Skip PEG alternatives that cannot match the next token
Rules with several alternatives now switch on the next token's type and only try the alternatives that may start with it. Loops stop as soon as the next token cannot start another iteration. Both use FIRST sets from a new analysis, which also replaces the old first_sets.py calculator. Co-authored-by: Pablo Galindo <pablogsal@gmail.com>
1 parent 8d11920 commit cba604e

12 files changed

Lines changed: 4641 additions & 144 deletions

File tree

‎Lib/test/test_peg_generator/test_c_generator.py‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import io
2+
import token
23
import unittest
34
from pathlib import PurePath
45
from unittest import mock
@@ -27,6 +28,10 @@ def emit_parser(self, parser):
2728
CParserEmitter(parser, output).emit()
2829
return output.getvalue()
2930

31+
def prepare_rules(self, source):
32+
parser = self.make_generator(source).prepare(PurePath("example.gram"))
33+
return {rule.signature.name: rule for rule in parser.rules}
34+
3035
def test_rule_types_distinguish_implicit_and_explicit_void_pointer(self):
3136
generator = self.make_generator("""
3237
start: &implicit implicit explicit
@@ -286,6 +291,106 @@ def test_keywords_are_numbered_once(self):
286291
self.assertEqual(generator.keywords, {"if": 500, "else": 501, "pass": 502})
287292
self.assertEqual(generator.soft_keywords, {"soft"})
288293

294+
def test_first_set_dispatch(self):
295+
rules = self.prepare_rules("""
296+
start: NAME | '(' NAME | 'if' NAME | "match" NUMBER
297+
""")
298+
self.assertEqual(
299+
rules["start"].first_set_dispatch,
300+
(
301+
frozenset({token.NAME}),
302+
frozenset({token.LPAR}),
303+
# Keyword types are numbered from 500.
304+
frozenset({500}),
305+
# Soft keywords are NAME tokens.
306+
frozenset({token.NAME}),
307+
),
308+
)
309+
310+
def test_first_set_dispatch_ignores_second_pass_alternatives(self):
311+
rules = self.prepare_rules("""
312+
start: NAME | invalid_start | NUMBER
313+
single: NAME | invalid_start
314+
invalid_start: STRING
315+
""")
316+
self.assertEqual(
317+
rules["start"].first_set_dispatch,
318+
(frozenset({token.NAME}), frozenset(), frozenset({token.NUMBER})),
319+
)
320+
self.assertIsNone(rules["single"].first_set_dispatch)
321+
322+
def test_no_first_set_dispatch(self):
323+
cases = {
324+
"single": "NAME",
325+
"same_sets": "NAME '+' NAME | NAME",
326+
"unknown": "~ NAME | [NUMBER]",
327+
}
328+
rules = self.prepare_rules(
329+
"start: " + " | ".join(cases) + "\n"
330+
+ "".join(f"{name}: {rhs}\n" for name, rhs in cases.items())
331+
)
332+
for name in cases:
333+
with self.subTest(name):
334+
self.assertIsNone(rules[name].first_set_dispatch)
335+
336+
def test_first_set_dispatch_in_second_pass_rules(self):
337+
rules = self.prepare_rules("""
338+
start: NAME | invalid_start
339+
invalid_start: (NAME | NUMBER) helper
340+
helper: NAME | NUMBER
341+
""")
342+
self.assertEqual(
343+
rules["helper"].first_set_dispatch,
344+
(frozenset({token.NAME}), frozenset({token.NUMBER})),
345+
)
346+
347+
def test_first_set_dispatch_emission(self):
348+
generator = self.make_generator("""
349+
start: disjoint | overlapping
350+
disjoint: NAME | NUMBER
351+
overlapping: NAME '+' NAME | NUMBER | ~ STRING
352+
""")
353+
source = self.emit_parser(generator.prepare(PurePath("example.gram")))
354+
disjoint = source[source.index("\ndisjoint_rule(") : source.index("\noverlapping_rule(")]
355+
self.assertIn(f"case {token.NAME}:\n goto _first_set_alt_0;", disjoint)
356+
self.assertNotIn("_first_set_mask", disjoint)
357+
overlapping = source[source.index("\noverlapping_rule(") :]
358+
# The alternative with an unknown first set is tried for every token.
359+
self.assertIn(
360+
f"case {token.NAME}:\n _first_set_mask = 0x5ULL;", overlapping
361+
)
362+
self.assertIn("default:\n _first_set_mask = 0x4ULL;", overlapping)
363+
364+
def test_loop_first_set_check(self):
365+
rules = self.prepare_rules("""
366+
start: element+ keyword* NAME* large*
367+
element: NAME | NUMBER
368+
keyword: 'if' NAME
369+
large: tstring_end NAME
370+
tstring_end: TSTRING_END
371+
""")
372+
checks = {}
373+
for name, rule in rules.items():
374+
if rule.signature.kind in {RuleKind.LOOP0, RuleKind.LOOP1}:
375+
alt, = rule.alternatives
376+
first = alt.calls[0]
377+
if first.function == "_PyPegen_next_token_in":
378+
checks[str(alt.text)] = first.arguments
379+
else:
380+
checks[str(alt.text)] = None
381+
self.assertEqual(
382+
checks,
383+
{
384+
"element": ("p", f"0x{1 << token.NAME | 1 << token.NUMBER:x}ULL", "0x0ULL"),
385+
# Keyword types are numbered from 500.
386+
"keyword": ("p", "0x0ULL", "0x1ULL"),
387+
# Matching the token costs no more than checking it.
388+
"NAME": None,
389+
# The mask has no bit for a token type above 63.
390+
"large": None,
391+
},
392+
)
393+
289394

290395
if __name__ == "__main__":
291396
unittest.main()

‎Lib/test/test_peg_generator/test_first_sets.py‎

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@
77
with test_tools.imports_under_tool("peg_generator"):
88
from pegen.grammar_parser import GeneratedParser as GrammarParser
99
from pegen.testutil import parse_string
10-
from pegen.first_sets import FirstSetCalculator
10+
from pegen.grammar_analysis import FirstSetVisitor
1111
from pegen.grammar import Grammar
1212

1313

1414
class TestFirstSets(unittest.TestCase):
15-
def calculate_first_sets(self, grammar_source: str) -> Dict[str, Set[str]]:
15+
def calculate_first_sets(self, grammar_source: str) -> Dict[str, Set[str] | None]:
1616
grammar: Grammar = parse_string(grammar_source, GrammarParser)
17-
return FirstSetCalculator(grammar.rules).calculate()
17+
first_sets = FirstSetVisitor(grammar.rules).first_sets
18+
return {
19+
name: None if first is None else set(first)
20+
for name, first in first_sets.items()
21+
}
1822

1923
def test_alternatives(self) -> None:
2024
grammar = """
@@ -178,10 +182,12 @@ def test_negative_lookahead(self) -> None:
178182
"""
179183
self.assertEqual(
180184
self.calculate_first_sets(grammar),
185+
# A negative lookahead doesn't narrow the first set, but its own
186+
# first set is included for the effects it may have.
181187
{
182188
"opt": {"'b'", "'a'", "'c'"},
183-
"expr": {"'b'", "'c'"},
184-
"start": {"'b'", "'c'"},
189+
"expr": {"'a'", "'b'", "'c'"},
190+
"start": {"'a'", "'b'", "'c'"},
185191
},
186192
)
187193

@@ -213,7 +219,8 @@ def test_advance_left_recursion(self) -> None:
213219
"""
214220
self.assertEqual(
215221
self.calculate_first_sets(grammar),
216-
{"sign": {"'-'", ""}, "start": {"'-'", "NUMBER"}},
222+
# sign may match whatever the next token is.
223+
{"sign": None, "start": None},
217224
)
218225

219226
def test_mutual_left_recursion(self) -> None:
@@ -226,21 +233,20 @@ def test_mutual_left_recursion(self) -> None:
226233
self.calculate_first_sets(grammar),
227234
{
228235
"foo": {"'D'", "'B'"},
229-
"bar": {"'D'"},
236+
"bar": {"'D'", "'B'"},
230237
"start": {"'D'", "'B'"},
231238
},
232239
)
233240

234241
def test_nasty_left_recursion(self) -> None:
235-
# TODO: Validate this
236242
grammar = """
237243
start: target '='
238244
target: maybe '+' | NAME
239245
maybe: maybe '-' | target
240246
"""
241247
self.assertEqual(
242248
self.calculate_first_sets(grammar),
243-
{"maybe": set(), "target": {"NAME"}, "start": {"NAME"}},
249+
{"maybe": {"NAME"}, "target": {"NAME"}, "start": {"NAME"}},
244250
)
245251

246252
def test_nullable_rule(self) -> None:
@@ -252,9 +258,9 @@ def test_nullable_rule(self) -> None:
252258
self.assertEqual(
253259
self.calculate_first_sets(grammar),
254260
{
255-
"sign": {"", "'-'"},
261+
"sign": None,
256262
"thing": {"NUMBER"},
257-
"start": {"NUMBER", "'-'"},
263+
"start": None,
258264
},
259265
)
260266

@@ -277,10 +283,60 @@ def test_multiple_nullable_rules(self) -> None:
277283
self.assertEqual(
278284
self.calculate_first_sets(grammar),
279285
{
280-
"sign": {"", "'-'"},
281-
"thing": {"'+'", ""},
282-
"start": {"'+'", "'-'", "'*'"},
286+
"sign": None,
287+
"thing": None,
288+
"start": None,
283289
"other": {"'*'"},
284290
"another": {"'/'"},
285291
},
286292
)
293+
294+
def test_keywords_and_exact_tokens(self) -> None:
295+
grammar = """
296+
start: 'if' NAME | '(' NAME ')'
297+
"""
298+
self.assertEqual(self.calculate_first_sets(grammar), {"start": {"'if'", "'('"}})
299+
300+
def test_soft_keywords(self) -> None:
301+
grammar = """
302+
start: "match" NAME | SOFT_KEYWORD NUMBER
303+
"""
304+
self.assertEqual(self.calculate_first_sets(grammar), {"start": {"NAME"}})
305+
306+
def test_leading_cut(self) -> None:
307+
grammar = """
308+
start: choice NEWLINE
309+
choice: ~ NAME | NUMBER
310+
"""
311+
self.assertEqual(
312+
self.calculate_first_sets(grammar), {"choice": None, "start": None}
313+
)
314+
315+
def test_forced(self) -> None:
316+
grammar = """
317+
start: choice NEWLINE
318+
choice: &&'+' | NAME &&':' NUMBER
319+
"""
320+
self.assertEqual(
321+
self.calculate_first_sets(grammar), {"choice": None, "start": None}
322+
)
323+
grammar = """
324+
start: NAME &&':' NUMBER
325+
"""
326+
self.assertEqual(self.calculate_first_sets(grammar), {"start": {"NAME"}})
327+
328+
def test_negative_lookahead_effects(self) -> None:
329+
grammar = """
330+
start: !guard NAME
331+
guard: NUMBER
332+
"""
333+
self.assertEqual(
334+
self.calculate_first_sets(grammar),
335+
{"guard": {"NUMBER"}, "start": {"NAME", "NUMBER"}},
336+
)
337+
338+
def test_positive_lookahead_alone(self) -> None:
339+
grammar = """
340+
start: &NUMBER
341+
"""
342+
self.assertEqual(self.calculate_first_sets(grammar), {"start": {"NUMBER"}})
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Speed up the parser by computing the first set of each rule and skipping
2+
alternatives that cannot start with the next token based on those first
3+
sets.

0 commit comments

Comments
 (0)