Skip to content

Commit 606289f

Browse files
Address review comments
Make the position deducer more careful: * A rule reference in a base alternative can match several items of the inserted alternative ("pair 'c'" with "pair: 'a' 'b'" shadows "'a' 'b' 'c' 'd'"). * A failure after a cut fails the whole rule, so "'a' 'c'" must be inserted before "'a' ~ 'b'". * An inserted alternative which can match the same code as the base alternative or its proper prefix would report a false error on valid code re-parsed in the second pass; never insert it before the base alternative. The only change in the generated parser: the error alternative in with_item is now inserted before the base alternative (this is equivalent, as its action only reports an error for an invalid target). An alternative inserted by a rule extension now always fails in a generated Python parser (which has no second parsing pass), like an alternative which references an invalid_* rule. NamedItem.__repr__() now includes the item type, so two groups which only differ by the item type do not share an artificial rule. build_parser() accepts path-like objects again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 695d71e commit 606289f

6 files changed

Lines changed: 192 additions & 38 deletions

File tree

Grammar/python_errors.gram

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -534,4 +534,3 @@ invalid_fstring_conversion_character:
534534
invalid_tstring_conversion_character:
535535
| '!' &(':' | '}') { RAISE_SYNTAX_ERROR_ON_NEXT_TOKEN("t-string: missing conversion character") }
536536
| '!' !NAME { RAISE_SYNTAX_ERROR_ON_NEXT_TOKEN("t-string: invalid conversion character") }
537-

Lib/test/test_peg_generator/test_pegen.py

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import difflib
33
import io
44
import os
5+
import pathlib
56
import tempfile
67
import textwrap
78
import unittest
@@ -256,6 +257,84 @@ def test_extend_rule_duplicates_alternative(self) -> None:
256257
with self.assertRaisesRegex(GrammarError, "duplicates the alternative"):
257258
merge_grammars([base_grammar, extension_grammar])
258259

260+
def test_extend_rule_deduction_rule_reference_in_base(self) -> None:
261+
# The base alternative can shadow the inserted alternative by
262+
# matching several of its items with a single rule reference.
263+
base_grammar_source = """
264+
start: rule_a NEWLINE
265+
rule_a: pair 'c' { PC } | 'x'
266+
pair: 'a' 'b'
267+
"""
268+
extension_grammar_source = """
269+
rule_a (extend):
270+
| 'a' 'b' 'c' 'd'
271+
"""
272+
base_grammar = parse_string(base_grammar_source, GrammarParser)
273+
extension_grammar = parse_string(extension_grammar_source, GrammarParser)
274+
merged = merge_grammars([base_grammar, extension_grammar])
275+
self.assertEqual(
276+
str(merged.rules["rule_a"]), "rule_a: 'a' 'b' 'c' 'd' | pair 'c' | 'x'"
277+
)
278+
279+
def test_extend_rule_deduction_cut_in_base(self) -> None:
280+
# The cut commits to the base alternative, so a failure after
281+
# it would never reach an alternative inserted after it.
282+
base_grammar_source = """
283+
start: rule_a NEWLINE
284+
rule_a: 'a' ~ 'b' { AB } | 'x'
285+
"""
286+
extension_grammar_source = """
287+
rule_a (extend):
288+
| 'a' 'c'
289+
"""
290+
base_grammar = parse_string(base_grammar_source, GrammarParser)
291+
extension_grammar = parse_string(extension_grammar_source, GrammarParser)
292+
merged = merge_grammars([base_grammar, extension_grammar])
293+
self.assertEqual(
294+
str(merged.rules["rule_a"]), "rule_a: 'a' 'c' | 'a' ~ 'b' | 'x'"
295+
)
296+
297+
def test_extend_rule_deduction_more_general_alternative(self) -> None:
298+
# An inserted alternative which can match all the code matched
299+
# by the base alternative would succeed (and report its error)
300+
# on valid code re-parsed in the second pass, so it is not
301+
# inserted before the base alternative.
302+
base_grammar_source = """
303+
start: rule_a NEWLINE
304+
rule_a: 'a' NAME { N } | 'x'
305+
"""
306+
extension_grammar_source = """
307+
rule_a (extend):
308+
| 'a' expr
309+
expr: NAME | NUMBER
310+
"""
311+
base_grammar = parse_string(base_grammar_source, GrammarParser)
312+
extension_grammar = parse_string(extension_grammar_source, GrammarParser)
313+
merged = merge_grammars([base_grammar, extension_grammar])
314+
self.assertEqual(
315+
str(merged.rules["rule_a"]), "rule_a: 'a' NAME | 'x' | 'a' expr"
316+
)
317+
318+
def test_extended_grammar_python_parser_skips_inserted_alternatives(self) -> None:
319+
# The Python parser generator has no second pass; an inserted
320+
# alternative fails like an alternative referencing an
321+
# invalid_* rule.
322+
base_grammar_source = """
323+
start: rule_a NEWLINE { rule_a }
324+
rule_a: 'a' { 1 }
325+
"""
326+
extension_grammar_source = """
327+
rule_a (extend):
328+
| 'a' 'b' { RAISE_SYNTAX_ERROR("nope") }
329+
"""
330+
base_grammar = parse_string(base_grammar_source, GrammarParser)
331+
extension_grammar = parse_string(extension_grammar_source, GrammarParser)
332+
merged = merge_grammars([base_grammar, extension_grammar])
333+
out = io.StringIO()
334+
genr = PythonParserGenerator(merged, out)
335+
genr.generate("<string>")
336+
self.assertNotIn("RAISE_SYNTAX_ERROR", out.getvalue())
337+
259338
def test_extend_rule_marks_inserted_alternatives_invalid(self) -> None:
260339
base_grammar_source = """
261340
start: rule_a NEWLINE
@@ -375,13 +454,23 @@ def test_extended_grammar_parses(self) -> None:
375454
extension_grammar = parse_string(extension_grammar_source, GrammarParser)
376455
merged = merge_grammars([base_grammar, extension_grammar])
377456
parser_class = generate_parser(merged)
378-
node = parse_string("a c\n", parser_class)
379-
self.assertEqual(node, "ac")
457+
# The Python parser generator has no second pass, so the
458+
# inserted alternative never matches.
459+
with self.assertRaises(SyntaxError):
460+
parse_string("a c\n", parser_class)
380461
node = parse_string("a b\n", parser_class)
381462
self.assertEqual(node, "ab")
382463
node = parse_string("a\n", parser_class)
383464
self.assertEqual(node, "a")
384465

466+
def test_build_parser_path_like(self) -> None:
467+
grammar_source = "start: NUMBER NEWLINE\n"
468+
with tempfile.TemporaryDirectory() as tmp_dir:
469+
grammar_file = pathlib.Path(tmp_dir) / "grammar.gram"
470+
grammar_file.write_text(grammar_source)
471+
grammar, parser, tokenizer = build_parser(grammar_file)
472+
self.assertEqual(list(grammar.rules), ["start"])
473+
385474
def test_build_parser_multiple_files(self) -> None:
386475
first_grammar_source = """
387476
start: the_rule NEWLINE | extra_rule

Parser/parser.c

Lines changed: 23 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Tools/peg_generator/pegen/build.py

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -401,9 +401,14 @@ def may_swallow(
401401
)
402402
base_items = [named_item.item for named_item in base_alt.items]
403403
inserted_items = [named_item.item for named_item in inserted_alt.items]
404-
return self._match_prefix(base_items, inserted_items)
404+
return self._match_prefix(base_items, inserted_items, seen)
405405

406-
def _match_prefix(self, base_items: list[Any], inserted_items: list[Any]) -> bool:
406+
def _match_prefix(
407+
self,
408+
base_items: list[Any],
409+
inserted_items: list[Any],
410+
seen: frozenset[str] = frozenset(),
411+
) -> bool:
407412
bi = ei = 0
408413
while True:
409414
# Skip non-consuming inserted items (over-approximation).
@@ -416,8 +421,9 @@ def _match_prefix(self, base_items: list[Any], inserted_items: list[Any]) -> boo
416421
return ei < len(inserted_items)
417422
base_item = base_items[bi]
418423
if isinstance(base_item, Cut):
419-
bi += 1
420-
continue
424+
# A failure after the cut fails the whole rule, so the
425+
# alternative must be inserted before this one.
426+
return True
421427
if isinstance(base_item, Lookahead):
422428
if ei < len(inserted_items):
423429
matches = self.overlap(base_item.node, inserted_items[ei])
@@ -431,7 +437,7 @@ def _match_prefix(self, base_items: list[Any], inserted_items: list[Any]) -> boo
431437
bi += 1
432438
continue
433439
if isinstance(base_item, (Opt, Repeat0)):
434-
if self._match_prefix(base_items[bi + 1:], inserted_items[ei:]):
440+
if self._match_prefix(base_items[bi + 1:], inserted_items[ei:], seen):
435441
return True
436442
if ei == len(inserted_items):
437443
return False
@@ -450,6 +456,20 @@ def _match_prefix(self, base_items: list[Any], inserted_items: list[Any]) -> boo
450456
bi += 1
451457
ei += 1
452458
continue
459+
if isinstance(base_item, NameLeaf):
460+
# A rule reference can match several inserted items;
461+
# check the rule's alternatives in its place.
462+
rule = self.rules.get(base_item.value)
463+
if rule is not None and rule.name not in seen:
464+
rest = base_items[bi + 1:]
465+
return any(
466+
self._match_prefix(
467+
[item.item for item in alt.items] + rest,
468+
inserted_items[ei:],
469+
seen | {rule.name},
470+
)
471+
for alt in rule.rhs.alts
472+
)
453473
inserted_heads, _ = _head_symbols(inserted_item)
454474
if inserted_heads & self.head_set(base_item):
455475
# The base item is more general: it can match the same
@@ -468,6 +488,40 @@ def _match_prefix(self, base_items: list[Any], inserted_items: list[Any]) -> boo
468488
)
469489
return False
470490

491+
def terminal_head_set(self, node: Any) -> set[str]:
492+
"""The token and string symbols which can start a match of the node."""
493+
return {
494+
symbol for symbol in self.head_set(node) if symbol not in self.rules
495+
}
496+
497+
def may_cover(self, inserted_alt: Alt, base_alt: Alt) -> bool:
498+
"""Whether inserted_alt may match the same code as base_alt or
499+
its proper prefix (every item is at least as general as the
500+
corresponding base item).
501+
502+
Inserting it before base_alt would report a false error on
503+
valid code re-parsed in the second pass.
504+
"""
505+
base_items = [
506+
named_item.item for named_item in base_alt.items
507+
if not isinstance(named_item.item, (Lookahead, Cut))
508+
]
509+
inserted_items = [
510+
named_item.item for named_item in inserted_alt.items
511+
if not isinstance(named_item.item, (Lookahead, Cut))
512+
]
513+
if len(inserted_items) > len(base_items):
514+
return False
515+
for base_item, inserted_item in zip(base_items, inserted_items):
516+
if str(base_item) == str(inserted_item):
517+
continue
518+
if not (
519+
self.terminal_head_set(base_item)
520+
<= self.terminal_head_set(inserted_item)
521+
):
522+
return False
523+
return True
524+
471525
def deduce_index(self, base_alts: list[Alt], inserted_alt: Alt) -> int | None:
472526
rule = self._referenced_rule(inserted_alt)
473527
if rule is not None:
@@ -481,8 +535,13 @@ def deduce_index(self, base_alts: list[Alt], inserted_alt: Alt) -> int | None:
481535
# shadow the new alternative; their relative order is
482536
# defined by the order of the extensions.
483537
continue
484-
if any(self.may_swallow(base_alt, alt) for alt in inserted_alts):
485-
return index
538+
if not any(
539+
self.may_swallow(base_alt, alt) for alt in inserted_alts
540+
):
541+
continue
542+
if any(self.may_cover(alt, base_alt) for alt in inserted_alts):
543+
continue
544+
return index
486545
return None
487546

488547

@@ -593,7 +652,7 @@ def build_parser(
593652
594653
The returned parser and tokenizer are those of the last grammar file.
595654
"""
596-
if isinstance(grammar_files, str):
655+
if isinstance(grammar_files, (str, os.PathLike)):
597656
grammar_files = [grammar_files]
598657
grammars: list[Grammar] = []
599658
for grammar_file in grammar_files:
@@ -686,8 +745,8 @@ def build_python_generator(
686745

687746

688747
def grammars_display_name(grammar_files: str | Sequence[str]) -> str:
689-
if isinstance(grammar_files, str):
690-
return grammar_files
748+
if isinstance(grammar_files, (str, os.PathLike)):
749+
return os.fspath(grammar_files)
691750
return ", ".join(os.path.basename(grammar_file) for grammar_file in grammar_files)
692751

693752

Tools/peg_generator/pegen/grammar.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,9 @@ def __str__(self) -> str:
220220
return str(self.item)
221221

222222
def __repr__(self) -> str:
223-
return f"NamedItem({self.name!r}, {self.item!r})"
223+
if self.type is None:
224+
return f"NamedItem({self.name!r}, {self.item!r})"
225+
return f"NamedItem({self.name!r}, {self.item!r}, {self.type!r})"
224226

225227
def __iter__(self) -> Iterator[Item]:
226228
yield self.item

Tools/peg_generator/pegen/python_generator.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,12 @@ def visit_Alt(self, node: Alt, is_loop: bool, is_gather: bool) -> None:
338338
self.print("):")
339339
with self.indent():
340340
action = node.action
341-
if not action:
341+
if node.invalid:
342+
# An inserted alternative has a C action and is only
343+
# used in the second parsing pass, which the Python
344+
# parser does not have; make it always fail.
345+
action = "UNREACHABLE"
346+
elif not action:
342347
if is_gather:
343348
assert len(self.local_variable_names) == 2
344349
action = (

0 commit comments

Comments
 (0)