|
1 | 1 | import io |
| 2 | +import textwrap |
2 | 3 | import unittest |
3 | | -from pathlib import PurePath |
| 4 | +from pathlib import Path, PurePath |
4 | 5 | from unittest import mock |
5 | 6 |
|
6 | 7 | from test import test_tools |
| 8 | +from test.support import os_helper |
7 | 9 |
|
8 | 10 | test_tools.skip_if_missing("peg_generator") |
9 | 11 | with test_tools.imports_under_tool("peg_generator"): |
10 | 12 | from pegen import grammar as grammar_module |
| 13 | + from pegen.build import build_parser, included_output_files |
11 | 14 | from pegen.c_generator import CParserGenerator |
12 | 15 | from pegen.c_generator_file import CParserEmitter |
13 | | - from pegen.grammar import NamedItem, RuleKind |
| 16 | + from pegen.grammar import GrammarError, NamedItem, RuleKind |
14 | 17 | from pegen.grammar_parser import GeneratedParser as GrammarParser |
15 | 18 | from pegen.testutil import ALL_TOKENS, EXACT_TOKENS, NON_EXACT_TOKENS, parse_string |
16 | 19 |
|
@@ -287,5 +290,144 @@ def test_keywords_are_numbered_once(self): |
287 | 290 | self.assertEqual(generator.soft_keywords, {"soft"}) |
288 | 291 |
|
289 | 292 |
|
| 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 | + |
290 | 432 | if __name__ == "__main__": |
291 | 433 | unittest.main() |
0 commit comments