Skip to content

Commit e548b47

Browse files
authored
gh-153195: Allow splitting the PEG grammar into several files (#159917)
Add an @include meta to pegen: the rules of an included grammar file are appended to the including file's rules. The C generator emits the rules of each included file into a separate C file next to the main output. Use it to move the invalid_* rules from Grammar/python.gram into Grammar/invalid.gram, which generates Parser/parser_invalid.c.
1 parent e03def8 commit e548b47

29 files changed

Lines changed: 18070 additions & 15358 deletions

‎.gitattributes‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ Modules/_testinternalcapi/test_cases.c.h generated
102102
Modules/_testinternalcapi/test_targets.h generated
103103
PC/python3dll.c generated
104104
Parser/parser.c generated
105+
Parser/parser_invalid.c generated
105106
Parser/token.c generated
106107
Programs/test_frozenmain.h generated
107108
Python/Python-ast.c generated

‎.github/CODEOWNERS‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ Python/optimizer_symbols.c @markshannon @tomasr8 @Fidget-Spinner
302302

303303
# Parser, Lexer, and Grammar
304304
Grammar/python.gram @pablogsal @lysnikolaou
305+
Grammar/invalid.gram @pablogsal @lysnikolaou
305306
Lib/test/test_peg_generator/ @pablogsal @lysnikolaou
306307
Lib/test/test_tokenize.py @pablogsal @lysnikolaou
307308
Lib/tokenize.py @pablogsal @lysnikolaou

‎Grammar/invalid.gram‎

Lines changed: 467 additions & 0 deletions
Large diffs are not rendered by default.

‎Grammar/python.gram‎

Lines changed: 2 additions & 470 deletions
Large diffs are not rendered by default.

‎InternalDocs/changing_grammar.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@ Below is a checklist of things that may need to change.
1313
## Checklist
1414

1515
* [`Grammar/python.gram`](../Grammar/python.gram): The grammar definition,
16-
with actions that build AST nodes.
17-
After changing it, run ``make regen-pegen`` (or ``build.bat --regen`` on Windows),
18-
to regenerate [`Parser/parser.c`](../Parser/parser.c).
19-
(This runs Python's parser generator, [`Tools/peg_generator`](../Tools/peg_generator)).
16+
with actions that build AST nodes. The rules for specialized syntax errors
17+
(`invalid_*` rules) are in [`Grammar/invalid.gram`](../Grammar/invalid.gram),
18+
included by `python.gram`. After changing either, run ``make regen-pegen``
19+
(or ``build.bat --regen`` on Windows), to regenerate
20+
[`Parser/parser.c`](../Parser/parser.c) and
21+
[`Parser/parser_invalid.c`](../Parser/parser_invalid.c). (This runs Python's
22+
parser generator, [`Tools/peg_generator`](../Tools/peg_generator)).
2023

2124
* [`Grammar/Tokens`](../Grammar/Tokens) is a place for adding new token types. After
2225
changing it, run ``make regen-token`` to regenerate

‎InternalDocs/parser.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,42 @@ Pegen has some special grammatical elements and rules:
498498
reporting incorrect locations due to the invalid rules).
499499
- The order of the alternatives involving invalid rules matter
500500
(like any rule in PEG).
501+
- These rules live in [`Grammar/invalid.gram`](../Grammar/invalid.gram),
502+
which is included from [`Grammar/python.gram`](../Grammar/python.gram).
503+
504+
A grammar can be split into several files with the `@include` meta, which
505+
takes a file name relative to the including file:
506+
507+
```
508+
@include "invalid.gram"
509+
```
510+
511+
The rules of each included file are appended, in order, to the rules of the
512+
including file, so the parser is the same as if the files were concatenated.
513+
Included files may themselves use `@include`, but may not define other metas
514+
(such as `@trailer`), and a rule can only be defined in one of the files.
515+
Includes are resolved by `pegen.build.build_parser()`, which every pegen
516+
command uses to load a grammar file. A grammar parsed some other way must be
517+
passed to `pegen.build.resolve_includes()` before generating a parser from it;
518+
otherwise the generator refuses it.
519+
520+
The C generator emits the rules of each included file into a separate C file,
521+
with the included file's name appended: for `invalid.gram` that is
522+
[`Parser/parser_invalid.c`](../Parser/parser_invalid.c). An included file in a
523+
subdirectory of the root grammar file's directory gets its C file in the same
524+
subdirectory of the output's directory (`sub/extra.gram` gives
525+
`Parser/sub/parser_extra.c`); included files outside that directory are
526+
rejected. This keeps the generated files smaller, and lets them be compiled
527+
separately. The keyword tables and the `@trailer` code are only in the main C
528+
file, while the `@header` and `@subheader` code is copied into every C file,
529+
so it must not define non-`static` functions or variables (they would be defined
530+
more than once). Rules that are called from another C file, including rules of
531+
included files that the trailer or a C action calls, can't be `static`; the
532+
generated code gives them a `_PyPegen_` prefix with a `#define`, since CPython
533+
requires that prefix on its non-static symbols, and marks them
534+
`Py_LOCAL_SYMBOL` so that they aren't exported from the shared library. Helper
535+
rules (for groups, repetitions and so on)
536+
are never shared between files: each C file gets its own `static` copy.
501537

502538
Tokenization
503539
------------

‎Lib/test/test_peg_generator/test_c_generator.py‎

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import io
2+
import textwrap
23
import unittest
3-
from pathlib import PurePath
4+
from pathlib import Path, PurePath
45
from unittest import mock
56

67
from test import test_tools
8+
from test.support import os_helper
79

810
test_tools.skip_if_missing("peg_generator")
911
with test_tools.imports_under_tool("peg_generator"):
1012
from pegen import grammar as grammar_module
13+
from pegen.build import build_parser, included_output_files
1114
from pegen.c_generator import CParserGenerator
1215
from pegen.c_generator_file import CParserEmitter
13-
from pegen.grammar import NamedItem, RuleKind
16+
from pegen.grammar import GrammarError, NamedItem, RuleKind
1417
from pegen.grammar_parser import GeneratedParser as GrammarParser
1518
from pegen.testutil import ALL_TOKENS, EXACT_TOKENS, NON_EXACT_TOKENS, parse_string
1619

@@ -287,5 +290,144 @@ def test_keywords_are_numbered_once(self):
287290
self.assertEqual(generator.soft_keywords, {"soft"})
288291

289292

293+
class TestIncludedCFiles(unittest.TestCase):
294+
def setUp(self):
295+
self.tmp_dir = self.enterContext(os_helper.temp_dir())
296+
297+
def write(self, name, source):
298+
path = Path(self.tmp_dir, name)
299+
path.parent.mkdir(parents=True, exist_ok=True)
300+
path.write_text(textwrap.dedent(source))
301+
return path
302+
303+
def generate(self, grammar_file):
304+
grammar, _, _ = build_parser(grammar_file)
305+
output = io.StringIO()
306+
included = {source: io.StringIO() for source in {
307+
rule.source for rule in grammar.rules.values() if rule.source is not None
308+
}}
309+
generator = CParserGenerator(
310+
grammar, ALL_TOKENS, EXACT_TOKENS, NON_EXACT_TOKENS, output,
311+
included_files=included,
312+
)
313+
generator.generate(grammar_file)
314+
return output.getvalue(), {
315+
source.name: file.getvalue() for source, file in included.items()
316+
}
317+
318+
def test_included_rules_go_to_their_own_file(self):
319+
main = self.write("main.gram", """\
320+
@include "extra.gram"
321+
start: item+ NEWLINE
322+
item: NAME ('+' | '-') | invalid_item
323+
""")
324+
self.write("extra.gram", """\
325+
invalid_item: NUMBER ('+' | '-') item
326+
""")
327+
root, included = self.generate(main)
328+
extra = included["extra.gram"]
329+
330+
self.assertTrue(root.startswith("// @generated by pegen from main.gram\n"))
331+
self.assertTrue(extra.startswith("// @generated by pegen from extra.gram\n"))
332+
# Each rule is defined in the C file of its grammar file, and only
333+
# the root file has the keyword tables and the trailer.
334+
self.assertIn("\nstart_rule(Parser *p)\n{", root)
335+
self.assertNotIn("\ninvalid_item_rule(Parser *p)\n{", root)
336+
self.assertIn("\ninvalid_item_rule(Parser *p)\n{", extra)
337+
self.assertNotIn("\nitem_rule(Parser *p)\n{", extra)
338+
self.assertIn("reserved_keywords", root)
339+
self.assertNotIn("reserved_keywords", extra)
340+
self.assertIn("_PyPegen_parse", root)
341+
self.assertNotIn("_PyPegen_parse", extra)
342+
# Rule type ids stay unique across the files.
343+
self.assertIn("#define start_type 1000\n", root)
344+
self.assertIn("#define item_type 1001\n", root)
345+
self.assertIn("#define invalid_item_type 1002\n", extra)
346+
# Rules called across files are not static and get a prefixed
347+
# linker name; the others stay static.
348+
for output in root, extra:
349+
self.assertIn("#define item_rule _PyPegen_item_rule\n", output)
350+
self.assertIn("#define invalid_item_rule _PyPegen_invalid_item_rule\n", output)
351+
self.assertNotIn("_PyPegen_start_rule", output)
352+
self.assertIn("static asdl_seq *_loop1_", root)
353+
self.assertIn("static void *\nstart_rule(Parser *p)", root)
354+
self.assertIn("\nPy_LOCAL_SYMBOL void *\nitem_rule(Parser *p)", root)
355+
self.assertIn("\nPy_LOCAL_SYMBOL void *\ninvalid_item_rule(Parser *p)", extra)
356+
# A helper for an identical subexpression is not shared between
357+
# files: each one has its own static copy.
358+
self.assertIn("static void *_tmp_", root)
359+
self.assertIn("static void *_tmp_", extra)
360+
361+
def test_trailer_can_call_rules_of_included_files(self):
362+
# The default trailer calls start_rule().
363+
main = self.write("main.gram", """\
364+
@include "rules.gram"
365+
item: NAME
366+
""")
367+
self.write("rules.gram", "start: item+ NEWLINE\n")
368+
root, included = self.generate(main)
369+
rules = included["rules.gram"]
370+
self.assertIn("return start_rule(p);", root)
371+
for output in root, rules:
372+
self.assertIn("#define start_rule _PyPegen_start_rule\n", output)
373+
self.assertIn("\nPy_LOCAL_SYMBOL void *start_rule(Parser *p);\n", output)
374+
self.assertIn("\nPy_LOCAL_SYMBOL void *\nstart_rule(Parser *p)\n{", rules)
375+
376+
def test_actions_can_call_rules_of_included_files(self):
377+
main = self.write("main.gram", """\
378+
@include "extra.gram"
379+
start: NAME NEWLINE { extra_rule(p) }
380+
""")
381+
self.write("extra.gram", "extra: NAME\n")
382+
root, included = self.generate(main)
383+
extra = included["extra.gram"]
384+
for output in root, extra:
385+
self.assertIn("#define extra_rule _PyPegen_extra_rule\n", output)
386+
self.assertIn("\nPy_LOCAL_SYMBOL void *extra_rule(Parser *p);\n", output)
387+
self.assertIn("\nPy_LOCAL_SYMBOL void *\nextra_rule(Parser *p)\n{", extra)
388+
389+
def test_without_includes_all_rules_are_static(self):
390+
main = self.write("main.gram", """\
391+
start: item NEWLINE
392+
item: NAME
393+
""")
394+
root, included = self.generate(main)
395+
self.assertEqual(included, {})
396+
self.assertNotIn("Rules shared", root)
397+
self.assertNotIn("_PyPegen_item_rule", root)
398+
self.assertIn("static void *\nitem_rule(Parser *p)", root)
399+
400+
def test_included_output_files(self):
401+
main = self.write("main.gram", """\
402+
@include "extra.gram"
403+
@include "sub/more.rules.gram"
404+
@include "a/same.gram"
405+
@include "b/same.gram"
406+
start: extra more one two NEWLINE
407+
""")
408+
self.write("extra.gram", "extra: NAME\n")
409+
self.write("sub/more.rules.gram", "more: NAME\n")
410+
self.write("a/same.gram", "one: NAME\n")
411+
self.write("b/same.gram", "two: NAME\n")
412+
grammar, _, _ = build_parser(main)
413+
out = Path("out", "parser.c.new")
414+
self.assertEqual(included_output_files(grammar, out), {
415+
Path("extra.gram"): Path("out", "parser_extra.c.new"),
416+
Path("sub", "more.rules.gram"): Path("out", "sub", "parser_more.rules.c.new"),
417+
Path("a", "same.gram"): Path("out", "a", "parser_same.c.new"),
418+
Path("b", "same.gram"): Path("out", "b", "parser_same.c.new"),
419+
})
420+
421+
def test_included_output_files_outside_output_directory(self):
422+
main = self.write("grammar/main.gram", """\
423+
@include "../outside.gram"
424+
start: outside NEWLINE
425+
""")
426+
self.write("outside.gram", "outside: NAME\n")
427+
grammar, _, _ = build_parser(main)
428+
with self.assertRaisesRegex(GrammarError, "would be outside the output directory"):
429+
included_output_files(grammar, Path("parser.c"))
430+
431+
290432
if __name__ == "__main__":
291433
unittest.main()

‎Lib/test/test_peg_generator/test_c_parser.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
test_tools.skip_if_missing("peg_generator")
2020
with test_tools.imports_under_tool("peg_generator"):
21+
from pegen.build import build_parser
2122
from pegen.grammar_parser import GeneratedParser as GrammarParser
2223
from pegen.testutil import (
2324
parse_string,
@@ -139,6 +140,9 @@ def build_extension(self, grammar_source):
139140

140141
def run_test(self, grammar_source, test_source):
141142
self.build_extension(grammar_source)
143+
self.run_extension_test(test_source)
144+
145+
def run_extension_test(self, test_source):
142146
test_source = textwrap.indent(textwrap.dedent(test_source), 8 * " ")
143147
assert_python_ok(
144148
"-c",
@@ -674,6 +678,36 @@ def test_soft_keywords_lookahead(self) -> None:
674678
"""
675679
self.run_test(grammar_source, test_source)
676680

681+
def test_included_grammar_file(self) -> None:
682+
# The rules of an included grammar file are compiled from a separate
683+
# C file, in the same subdirectory as the grammar file; rules call
684+
# each other across the two files.
685+
grammar_dir = self.enterContext(os_helper.temp_dir())
686+
with open(os.path.join(grammar_dir, "main.gram"), "w") as f:
687+
f.write(textwrap.dedent("""\
688+
@include "errors/invalid.gram"
689+
start: stmt+ NEWLINE? ENDMARKER
690+
stmt: invalid_stmt | NAME '=' expr
691+
expr: NAME | NUMBER
692+
"""))
693+
os.mkdir(os.path.join(grammar_dir, "errors"))
694+
with open(os.path.join(grammar_dir, "errors", "invalid.gram"), "w") as f:
695+
f.write(textwrap.dedent("""\
696+
invalid_stmt: expr '==' expr { RAISE_SYNTAX_ERROR("use '=' to assign") }
697+
"""))
698+
grammar, _, _ = build_parser(Path(grammar_dir, "main.gram"))
699+
generate_parser_c_extension(grammar, Path('.'), library_dir=self.library_dir)
700+
self.assertTrue(os.path.exists(os.path.join("errors", "parse_invalid.c")))
701+
self.run_extension_test("""
702+
self.check_input_strings_for_grammar(
703+
valid_cases=["a = b", "a = 1"],
704+
invalid_cases=["a =", "a == "],
705+
)
706+
with self.assertRaises(SyntaxError) as e:
707+
parse.parse_string("a == b", mode=0)
708+
self.assertIn("use '=' to assign", str(e.exception))
709+
""")
710+
677711
def test_forced(self) -> None:
678712
grammar_source = """
679713
start: NAME &&':' | NAME

0 commit comments

Comments
 (0)