* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Behat\Gherkin; use Behat\Gherkin\Exception\LexerException; use Behat\Gherkin\Exception\ParserException; use Behat\Gherkin\Node\BackgroundNode; use Behat\Gherkin\Node\ExampleTableNode; use Behat\Gherkin\Node\FeatureNode; use Behat\Gherkin\Node\OutlineNode; use Behat\Gherkin\Node\PyStringNode; use Behat\Gherkin\Node\ScenarioInterface; use Behat\Gherkin\Node\ScenarioNode; use Behat\Gherkin\Node\StepNode; use Behat\Gherkin\Node\TableNode; /** * Gherkin parser. * * $lexer = new Behat\Gherkin\Lexer($keywords); * $parser = new Behat\Gherkin\Parser($lexer); * $featuresArray = $parser->parse('/path/to/feature.feature'); * * @author Konstantin Kudryashov */ class Parser { private $lexer; private $input; private $file; private $tags = array(); private $languageSpecifierLine; /** * Initializes parser. * * @param Lexer $lexer Lexer instance */ public function __construct(Lexer $lexer) { $this->lexer = $lexer; } /** * Parses input & returns features array. * * @param string $input Gherkin string document * @param string $file File name * * @return FeatureNode|null * * @throws ParserException */ public function parse($input, $file = null) { $this->languageSpecifierLine = null; $this->input = $input; $this->file = $file; $this->tags = array(); try { $this->lexer->analyse($this->input, 'en'); } catch (LexerException $e) { throw new ParserException( sprintf('Lexer exception "%s" thrown for file %s', $e->getMessage(), $file), 0, $e ); } $feature = null; while ('EOS' !== ($predicted = $this->predictTokenType())) { $node = $this->parseExpression(); if (null === $node || "\n" === $node) { continue; } if (!$feature && $node instanceof FeatureNode) { $feature = $node; continue; } if ($feature && $node instanceof FeatureNode) { throw new ParserException(sprintf( 'Only one feature is allowed per feature file. But %s got multiple.', $this->file )); } if (is_string($node)) { throw new ParserException(sprintf( 'Expected Feature, but got text: "%s"%s', $node, $this->file ? ' in file: ' . $this->file : '' )); } if (!$node instanceof FeatureNode) { throw new ParserException(sprintf( 'Expected Feature, but got %s on line: %d%s', $node->getKeyword(), $node->getLine(), $this->file ? ' in file: ' . $this->file : '' )); } } return $feature; } /** * Returns next token if it's type equals to expected. * * @param string $type Token type * * @return array * * @throws Exception\ParserException */ protected function expectTokenType($type) { $types = (array) $type; if (in_array($this->predictTokenType(), $types)) { return $this->lexer->getAdvancedToken(); } $token = $this->lexer->predictToken(); throw new ParserException(sprintf( 'Expected %s token, but got %s on line: %d%s', implode(' or ', $types), $this->predictTokenType(), $token['line'], $this->file ? ' in file: ' . $this->file : '' )); } /** * Returns next token if it's type equals to expected. * * @param string $type Token type * * @return null|array */ protected function acceptTokenType($type) { if ($type !== $this->predictTokenType()) { return null; } return $this->lexer->getAdvancedToken(); } /** * Returns next token type without real input reading (prediction). * * @return string */ protected function predictTokenType() { $token = $this->lexer->predictToken(); return $token['type']; } /** * Parses current expression & returns Node. * * @return string|FeatureNode|BackgroundNode|ScenarioNode|OutlineNode|TableNode|StepNode * * @throws ParserException */ protected function parseExpression() { switch ($type = $this->predictTokenType()) { case 'Feature': return $this->parseFeature(); case 'Background': return $this->parseBackground(); case 'Scenario': return $this->parseScenario(); case 'Outline': return $this->parseOutline(); case 'Examples': return $this->parseExamples(); case 'TableRow': return $this->parseTable(); case 'PyStringOp': return $this->parsePyString(); case 'Step': return $this->parseStep(); case 'Text': return $this->parseText(); case 'Newline': return $this->parseNewline(); case 'Tag': return $this->parseTags(); case 'Comment': return $this->parseComment(); case 'Language': return $this->parseLanguage(); case 'EOS': return ''; } throw new ParserException(sprintf('Unknown token type: %s', $type)); } /** * Parses feature token & returns it's node. * * @return FeatureNode * * @throws ParserException */ protected function parseFeature() { $token = $this->expectTokenType('Feature'); $title = trim($token['value']) ?: null; $description = null; $tags = $this->popTags(); $background = null; $scenarios = array(); $keyword = $token['keyword']; $language = $this->lexer->getLanguage(); $file = $this->file; $line = $token['line']; // Parse description, background, scenarios & outlines while ('EOS' !== $this->predictTokenType()) { $node = $this->parseExpression(); if (is_string($node)) { $text = preg_replace('/^\s{0,' . ($token['indent'] + 2) . '}|\s*$/', '', $node); $description .= (null !== $description ? "\n" : '') . $text; continue; } if (!$background && $node instanceof BackgroundNode) { $background = $node; continue; } if ($node instanceof ScenarioInterface) { $scenarios[] = $node; continue; } if ($background instanceof BackgroundNode && $node instanceof BackgroundNode) { throw new ParserException(sprintf( 'Each Feature could have only one Background, but found multiple on lines %d and %d%s', $background->getLine(), $node->getLine(), $this->file ? ' in file: ' . $this->file : '' )); } if (!$node instanceof ScenarioNode) { throw new ParserException(sprintf( 'Expected Scenario, Outline or Background, but got %s on line: %d%s', $node->getNodeType(), $node->getLine(), $this->file ? ' in file: ' . $this->file : '' )); } } return new FeatureNode( rtrim($title) ?: null, rtrim($description) ?: null, $tags, $background, $scenarios, $keyword, $language, $file, $line ); } /** * Parses background token & returns it's node. * * @return BackgroundNode * * @throws ParserException */ protected function parseBackground() { $token = $this->expectTokenType('Background'); $title = trim($token['value']); $keyword = $token['keyword']; $line = $token['line']; if (count($this->popTags())) { throw new ParserException(sprintf( 'Background can not be tagged, but it is on line: %d%s', $line, $this->file ? ' in file: ' . $this->file : '' )); } // Parse description and steps $steps = array(); $allowedTokenTypes = array('Step', 'Newline', 'Text', 'Comment'); while (in_array($this->predictTokenType(), $allowedTokenTypes)) { $node = $this->parseExpression(); if ($node instanceof StepNode) { $steps[] = $this->normalizeStepNodeKeywordType($node, $steps); continue; } if (!count($steps) && is_string($node)) { $text = preg_replace('/^\s{0,' . ($token['indent'] + 2) . '}|\s*$/', '', $node); $title .= "\n" . $text; continue; } if ("\n" === $node) { continue; } if (is_string($node)) { throw new ParserException(sprintf( 'Expected Step, but got text: "%s"%s', $node, $this->file ? ' in file: ' . $this->file : '' )); } if (!$node instanceof StepNode) { throw new ParserException(sprintf( 'Expected Step, but got %s on line: %d%s', $node->getNodeType(), $node->getLine(), $this->file ? ' in file: ' . $this->file : '' )); } } return new BackgroundNode(rtrim($title) ?: null, $steps, $keyword, $line); } /** * Parses scenario token & returns it's node. * * @return ScenarioNode * * @throws ParserException */ protected function parseScenario() { $token = $this->expectTokenType('Scenario'); $title = trim($token['value']); $tags = $this->popTags(); $keyword = $token['keyword']; $line = $token['line']; // Parse description and steps $steps = array(); while (in_array($this->predictTokenType(), array('Step', 'Newline', 'Text', 'Comment'))) { $node = $this->parseExpression(); if ($node instanceof StepNode) { $steps[] = $this->normalizeStepNodeKeywordType($node, $steps); continue; } if (!count($steps) && is_string($node)) { $text = preg_replace('/^\s{0,' . ($token['indent'] + 2) . '}|\s*$/', '', $node); $title .= "\n" . $text; continue; } if ("\n" === $node) { continue; } if (is_string($node)) { throw new ParserException(sprintf( 'Expected Step, but got text: "%s"%s', $node, $this->file ? ' in file: ' . $this->file : '' )); } if (!$node instanceof StepNode) { throw new ParserException(sprintf( 'Expected Step, but got %s on line: %d%s', $node->getNodeType(), $node->getLine(), $this->file ? ' in file: ' . $this->file : '' )); } } return new ScenarioNode(rtrim($title) ?: null, $tags, $steps, $keyword, $line); } /** * Parses scenario outline token & returns it's node. * * @return OutlineNode * * @throws ParserException */ protected function parseOutline() { $token = $this->expectTokenType('Outline'); $title = trim($token['value']); $tags = $this->popTags(); $keyword = $token['keyword']; $examples = null; $line = $token['line']; // Parse description, steps and examples $steps = array(); while (in_array($this->predictTokenType(), array('Step', 'Examples', 'Newline', 'Text', 'Comment'))) { $node = $this->parseExpression(); if ($node instanceof StepNode) { $steps[] = $this->normalizeStepNodeKeywordType($node, $steps); continue; } if ($node instanceof ExampleTableNode) { $examples = $node; continue; } if (!count($steps) && is_string($node)) { $text = preg_replace('/^\s{0,' . ($token['indent'] + 2) . '}|\s*$/', '', $node); $title .= "\n" . $text; continue; } if ("\n" === $node) { continue; } if (is_string($node)) { throw new ParserException(sprintf( 'Expected Step or Examples table, but got text: "%s"%s', $node, $this->file ? ' in file: ' . $this->file : '' )); } if (!$node instanceof StepNode) { throw new ParserException(sprintf( 'Expected Step or Examples table, but got %s on line: %d%s', $node->getNodeType(), $node->getLine(), $this->file ? ' in file: ' . $this->file : '' )); } } if (null === $examples) { throw new ParserException(sprintf( 'Outline should have examples table, but got none for outline "%s" on line: %d%s', rtrim($title), $line, $this->file ? ' in file: ' . $this->file : '' )); } return new OutlineNode(rtrim($title) ?: null, $tags, $steps, $examples, $keyword, $line); } /** * Parses step token & returns it's node. * * @return StepNode */ protected function parseStep() { $token = $this->expectTokenType('Step'); $keyword = $token['value']; $keywordType = $token['keyword_type']; $text = trim($token['text']); $line = $token['line']; $arguments = array(); while (in_array($predicted = $this->predictTokenType(), array('PyStringOp', 'TableRow', 'Newline', 'Comment'))) { if ('Comment' === $predicted || 'Newline' === $predicted) { $this->acceptTokenType($predicted); continue; } $node = $this->parseExpression(); if ($node instanceof PyStringNode || $node instanceof TableNode) { $arguments[] = $node; } } return new StepNode($keyword, $text, $arguments, $line, $keywordType); } /** * Parses examples table node. * * @return ExampleTableNode */ protected function parseExamples() { $token = $this->expectTokenType('Examples'); $keyword = $token['keyword']; return new ExampleTableNode($this->parseTableRows(), $keyword); } /** * Parses table token & returns it's node. * * @return TableNode */ protected function parseTable() { return new TableNode($this->parseTableRows()); } /** * Parses PyString token & returns it's node. * * @return PyStringNode */ protected function parsePyString() { $token = $this->expectTokenType('PyStringOp'); $line = $token['line']; $strings = array(); while ('PyStringOp' !== ($predicted = $this->predictTokenType()) && 'Text' === $predicted) { $token = $this->expectTokenType('Text'); $strings[] = $token['value']; } $this->expectTokenType('PyStringOp'); return new PyStringNode($strings, $line); } /** * Parses tags. * * @return BackgroundNode|FeatureNode|OutlineNode|ScenarioNode|StepNode|TableNode|string */ protected function parseTags() { $token = $this->expectTokenType('Tag'); $this->tags = array_merge($this->tags, $token['tags']); return $this->parseExpression(); } /** * Returns current set of tags and clears tag buffer. * * @return array */ protected function popTags() { $tags = $this->tags; $this->tags = array(); return $tags; } /** * Parses next text line & returns it. * * @return string */ protected function parseText() { $token = $this->expectTokenType('Text'); return $token['value']; } /** * Parses next newline & returns \n. * * @return string */ protected function parseNewline() { $this->expectTokenType('Newline'); return "\n"; } /** * Parses next comment token & returns it's string content. * * @return BackgroundNode|FeatureNode|OutlineNode|ScenarioNode|StepNode|TableNode|string */ protected function parseComment() { $this->expectTokenType('Comment'); return $this->parseExpression(); } /** * Parses language block and updates lexer configuration based on it. * * @return BackgroundNode|FeatureNode|OutlineNode|ScenarioNode|StepNode|TableNode|string * * @throws ParserException */ protected function parseLanguage() { $token = $this->expectTokenType('Language'); if (null === $this->languageSpecifierLine) { $this->lexer->analyse($this->input, $token['value']); $this->languageSpecifierLine = $token['line']; } elseif ($token['line'] !== $this->languageSpecifierLine) { throw new ParserException(sprintf( 'Ambiguous language specifiers on lines: %d and %d%s', $this->languageSpecifierLine, $token['line'], $this->file ? ' in file: ' . $this->file : '' )); } return $this->parseExpression(); } /** * Parses the rows of a table * * @return string[][] */ private function parseTableRows() { $table = array(); while (in_array($predicted = $this->predictTokenType(), array('TableRow', 'Newline', 'Comment'))) { if ('Comment' === $predicted || 'Newline' === $predicted) { $this->acceptTokenType($predicted); continue; } $token = $this->expectTokenType('TableRow'); $table[$token['line']] = $token['columns']; } return $table; } /** * Changes step node type for types But, And to type of previous step if it exists else sets to Given * * @param StepNode $node * @param StepNode[] $steps * @return StepNode */ private function normalizeStepNodeKeywordType(StepNode $node, array $steps = array()) { if (in_array($node->getKeywordType(), array('And', 'But'))) { if (($prev = end($steps))) { $keywordType = $prev->getKeywordType(); } else { $keywordType = 'Given'; } $node = new StepNode( $node->getKeyword(), $node->getText(), $node->getArguments(), $node->getLine(), $keywordType ); } return $node; } }