packages/bison-parser/spec/Context/GrammarContext.php

1<?php
2
3declare(strict_types=1);
4
5namespace Spec\Context;
6
7use Behat\Behat\Context\Context;
8use Behat\Gherkin\Node\PyStringNode;
9use Behat\Step\Given;
10use Behat\Step\Then;
11use Behat\Step\When;
12use BisonParser\Ast\GrammarFile;
13use BisonParser\Parser;
14use BisonParser\SyntaxException;
15use PHPUnit\Framework\Assert;
16use PHPUnit\Framework\AssertionFailedError;
17
18/**
19 * Steps of the specification: give a grammar file, parse it with the public
20 * parser, and state either the whole resulting tree or the position at which
21 * parsing fails.
22 */
23final class GrammarContext implements Context
24{
25    private string $source = '';
26
27    private ?GrammarFile $tree = null;
28
29    private ?SyntaxException $failure = null;
30
31    /**
32     * Wires the parser under specification and the dumper that renders its trees.
33     *
34     * @param Parser $parser The parser every scenario runs
35     * @param TreeDumper $dumper Renders a tree as the lines a scenario states
36     */
37    public function __construct(
38        private readonly Parser $parser = new Parser(),
39        private readonly TreeDumper $dumper = new TreeDumper(),
40    ) {
41    }
42
43    /**
44     * Takes the grammar file of the scenario, ending it with a newline as files do.
45     *
46     * @param PyStringNode $file The text of the grammar file
47     */
48    #[Given('the grammar file:')]
49    public function theGrammarFile(PyStringNode $file): void
50    {
51        $this->source = $file->getRaw() . "\n";
52        $this->tree = null;
53        $this->failure = null;
54    }
55
56    /**
57     * Takes the grammar file of the scenario exactly as written, without a final newline.
58     *
59     * @param PyStringNode $file The text of the grammar file
60     */
61    #[Given('the grammar file without a final newline:')]
62    public function theGrammarFileWithoutAFinalNewline(PyStringNode $file): void
63    {
64        $this->source = $file->getRaw();
65        $this->tree = null;
66        $this->failure = null;
67    }
68
69    /**
70     * Runs the parser on the grammar file, keeping either the tree or the syntax error.
71     */
72    #[When('the file is parsed')]
73    public function theFileIsParsed(): void
74    {
75        try {
76            $this->tree = $this->parser->parse($this->source);
77        } catch (SyntaxException $failure) {
78            $this->failure = $failure;
79        }
80    }
81
82    /**
83     * States the whole tree the parse produced, one node per line.
84     *
85     * @param PyStringNode $expected The rendering of the tree, as TreeDumper writes it
86     *
87     * @throws AssertionFailedError When parsing failed or produced another tree
88     */
89    #[Then('the tree is:')]
90    public function theTreeIs(PyStringNode $expected): void
91    {
92        Assert::assertSame($expected->getRaw(), $this->dumper->dump($this->tree()));
93    }
94
95    /**
96     * States that the parse produced the same tree as another grammar file does.
97     *
98     * @param PyStringNode $other The other grammar file, ended with a newline
99     *
100     * @throws AssertionFailedError When parsing failed or the trees differ
101     */
102    #[Then('the tree is the same as for:')]
103    public function theTreeIsTheSameAsFor(PyStringNode $other): void
104    {
105        Assert::assertSame($this->dumper->dump($this->parser->parse($other->getRaw() . "\n")), $this->dumper->dump($this->tree()));
106    }
107
108    /**
109     * States that parsing failed, and where.
110     *
111     * @param string $line The line of the error, counted from 1
112     * @param string $column The column of the error, counted from 1
113     *
114     * @throws AssertionFailedError When parsing succeeded or failed elsewhere
115     */
116    #[Then('parsing fails at line :line column :column')]
117    public function parsingFailsAt(string $line, string $column): void
118    {
119        if ($this->failure === null) {
120            throw new AssertionFailedError("Parsing succeeded:\n" . $this->dumper->dump($this->tree()));
121        }
122        Assert::assertSame("{$line}:{$column}", (string) $this->failure->location, $this->failure->getMessage());
123    }
124
125    /**
126     * The tree of the last parse.
127     *
128     * @return GrammarFile The tree
129     *
130     * @throws AssertionFailedError When there was no parse, or it failed
131     */
132    public function tree(): GrammarFile
133    {
134        if ($this->failure !== null) {
135            throw new AssertionFailedError('Parsing failed: ' . $this->failure->getMessage());
136        }
137        if ($this->tree === null) {
138            throw new AssertionFailedError('The file has not been parsed');
139        }
140
141        return $this->tree;
142    }
143}
144