Skip to content

Commit 84f5a95

Browse files
committed
gh-153195: Allow splitting the PEG grammar into several files
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 34fe59b commit 84f5a95

29 files changed

Lines changed: 18045 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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,40 @@ 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 calls, can't be `static`; the generated code
532+
gives them a `_PyPegen_` prefix with a `#define`, since CPython requires that
533+
prefix on its non-static symbols. Helper rules (for groups, repetitions and so on)
534+
are never shared between files: each C file gets its own `static` copy.
501535

502536
Tokenization
503537
------------

‎Lib/test/test_peg_generator/test_c_generator.py‎

Lines changed: 131 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

@@ -278,5 +281,131 @@ def test_lowering_snapshots_symbols_and_tokens(self):
278281
self.assertEqual(lowerer.prepare_rule(start), expected)
279282

280283

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