├── src ├── Exception │ ├── Exception.php │ ├── LexerException.php │ ├── NodeException.php │ ├── ParserException.php │ ├── FilesystemException.php │ ├── CacheException.php │ ├── NoSuchLanguageException.php │ ├── InvalidTagContentException.php │ ├── UnexpectedParserNodeException.php │ └── UnexpectedTaggedNodeException.php ├── Node │ ├── DescribableNodeInterface.php │ ├── ArgumentInterface.php │ ├── NamedScenarioInterface.php │ ├── ScenarioInterface.php │ ├── ScenarioLikeInterface.php │ ├── NodeInterface.php │ ├── KeywordNodeInterface.php │ ├── StepContainerInterface.php │ ├── TaggedNodeTrait.php │ ├── TaggedNodeInterface.php │ ├── PyStringNode.php │ ├── ExampleTableNode.php │ ├── BackgroundNode.php │ ├── ScenarioNode.php │ ├── StepNode.php │ ├── FeatureNode.php │ ├── OutlineNode.php │ ├── ExampleNode.php │ └── TableNode.php ├── Dialect │ ├── DialectProviderInterface.php │ ├── CucumberDialectProvider.php │ ├── KeywordsDialectProvider.php │ └── GherkinDialect.php ├── Loader │ ├── FileLoaderInterface.php │ ├── AbstractLoader.php │ ├── LoaderInterface.php │ ├── DirectoryLoader.php │ ├── YamlFileLoader.php │ ├── GherkinFileLoader.php │ ├── AbstractFileLoader.php │ ├── CucumberNDJsonAstLoader.php │ └── ArrayLoader.php ├── Filter │ ├── FilterInterface.php │ ├── ComplexFilterInterface.php │ ├── FeatureFilterInterface.php │ ├── SimpleFilter.php │ ├── NarrativeFilter.php │ ├── ComplexFilter.php │ ├── RoleFilter.php │ ├── PathsFilter.php │ ├── NameFilter.php │ ├── LineFilter.php │ ├── LineRangeFilter.php │ └── TagFilter.php ├── Keywords │ ├── CachedArrayKeywords.php │ ├── KeywordsInterface.php │ ├── CucumberKeywords.php │ ├── DialectKeywords.php │ ├── ArrayKeywords.php │ └── KeywordsDumper.php ├── ParserInterface.php ├── Cache │ ├── CacheInterface.php │ ├── MemoryCache.php │ └── FileCache.php ├── GherkinCompatibilityMode.php ├── Gherkin.php ├── Filesystem.php └── Parser.php ├── LICENSE ├── composer.json └── README.md /src/Exception/Exception.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Exception; 12 | 13 | interface Exception 14 | { 15 | } 16 | -------------------------------------------------------------------------------- /src/Exception/LexerException.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Exception; 12 | 13 | use RuntimeException; 14 | 15 | class LexerException extends RuntimeException implements Exception 16 | { 17 | } 18 | -------------------------------------------------------------------------------- /src/Exception/NodeException.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Exception; 12 | 13 | use RuntimeException; 14 | 15 | class NodeException extends RuntimeException implements Exception 16 | { 17 | } 18 | -------------------------------------------------------------------------------- /src/Exception/ParserException.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Exception; 12 | 13 | use RuntimeException; 14 | 15 | class ParserException extends RuntimeException implements Exception 16 | { 17 | } 18 | -------------------------------------------------------------------------------- /src/Exception/FilesystemException.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Exception; 12 | 13 | use RuntimeException; 14 | 15 | class FilesystemException extends RuntimeException implements Exception 16 | { 17 | } 18 | -------------------------------------------------------------------------------- /src/Node/DescribableNodeInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | interface DescribableNodeInterface 14 | { 15 | /** 16 | * @return ?string 17 | */ 18 | public function getDescription(); 19 | } 20 | -------------------------------------------------------------------------------- /src/Node/ArgumentInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | /** 14 | * Gherkin arguments interface. 15 | * 16 | * @author Konstantin Kudryashov 17 | */ 18 | interface ArgumentInterface extends NodeInterface 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /src/Node/NamedScenarioInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | interface NamedScenarioInterface 14 | { 15 | /** 16 | * Returns the human-readable name of the scenario. 17 | */ 18 | public function getName(): ?string; 19 | } 20 | -------------------------------------------------------------------------------- /src/Node/ScenarioInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | /** 14 | * Gherkin scenario interface. 15 | * 16 | * @author Konstantin Kudryashov 17 | */ 18 | interface ScenarioInterface extends ScenarioLikeInterface, TaggedNodeInterface 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /src/Exception/CacheException.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Exception; 12 | 13 | use RuntimeException; 14 | 15 | /** 16 | * Cache exception. 17 | * 18 | * @author Konstantin Kudryashov 19 | */ 20 | class CacheException extends RuntimeException implements Exception 21 | { 22 | } 23 | -------------------------------------------------------------------------------- /src/Node/ScenarioLikeInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | /** 14 | * Gherkin scenario-like interface. 15 | * 16 | * @author Konstantin Kudryashov 17 | */ 18 | interface ScenarioLikeInterface extends KeywordNodeInterface, StepContainerInterface 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /src/Exception/NoSuchLanguageException.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Exception; 12 | 13 | final class NoSuchLanguageException extends ParserException 14 | { 15 | public function __construct(public readonly string $language) 16 | { 17 | parent::__construct('Language not supported: ' . $language); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Dialect/DialectProviderInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Dialect; 12 | 13 | use Behat\Gherkin\Exception\NoSuchLanguageException; 14 | 15 | interface DialectProviderInterface 16 | { 17 | /** 18 | * @param non-empty-string $language 19 | * 20 | * @throws NoSuchLanguageException when the language is not supported 21 | */ 22 | public function getDialect(string $language): GherkinDialect; 23 | 24 | public function getDefaultDialect(): GherkinDialect; 25 | } 26 | -------------------------------------------------------------------------------- /src/Loader/FileLoaderInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Loader; 12 | 13 | /** 14 | * File Loader interface. 15 | * 16 | * @author Konstantin Kudryashov 17 | * 18 | * @template TResourceType 19 | * 20 | * @extends LoaderInterface 21 | */ 22 | interface FileLoaderInterface extends LoaderInterface 23 | { 24 | /** 25 | * Sets base features path. 26 | * 27 | * @return void 28 | */ 29 | public function setBasePath(string $path); 30 | } 31 | -------------------------------------------------------------------------------- /src/Node/NodeInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | /** 14 | * Gherkin node interface. 15 | * 16 | * @author Konstantin Kudryashov 17 | */ 18 | interface NodeInterface 19 | { 20 | /** 21 | * Returns node type string. 22 | * 23 | * @return string 24 | */ 25 | public function getNodeType(); 26 | 27 | /** 28 | * Returns feature declaration line number. 29 | * 30 | * @return int 31 | */ 32 | public function getLine(); 33 | } 34 | -------------------------------------------------------------------------------- /src/Node/KeywordNodeInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | /** 14 | * Gherkin keyword node interface. 15 | * 16 | * @author Konstantin Kudryashov 17 | */ 18 | interface KeywordNodeInterface extends NodeInterface 19 | { 20 | /** 21 | * Returns node keyword. 22 | * 23 | * @return string 24 | */ 25 | public function getKeyword(); 26 | 27 | /** 28 | * Returns node title. 29 | * 30 | * @return string|null 31 | */ 32 | public function getTitle(); 33 | } 34 | -------------------------------------------------------------------------------- /src/Exception/InvalidTagContentException.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Exception; 12 | 13 | class InvalidTagContentException extends ParserException 14 | { 15 | public function __construct(string $tag, ?string $file) 16 | { 17 | parent::__construct( 18 | sprintf( 19 | 'Tags cannot include whitespace, found "%s"%s', 20 | $tag, 21 | is_string($file) 22 | ? "in file {$file}" 23 | : '' 24 | ), 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Node/StepContainerInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | /** 14 | * Gherkin step container interface. 15 | * 16 | * @author Konstantin Kudryashov 17 | */ 18 | interface StepContainerInterface extends NodeInterface 19 | { 20 | /** 21 | * Checks if container has steps. 22 | * 23 | * @return bool 24 | */ 25 | public function hasSteps(); 26 | 27 | /** 28 | * Returns container steps. 29 | * 30 | * @return list 31 | */ 32 | public function getSteps(); 33 | } 34 | -------------------------------------------------------------------------------- /src/Filter/FilterInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Filter; 12 | 13 | use Behat\Gherkin\Node\ScenarioInterface; 14 | 15 | /** 16 | * Filter interface. 17 | * 18 | * @author Konstantin Kudryashov 19 | */ 20 | interface FilterInterface extends FeatureFilterInterface 21 | { 22 | /** 23 | * Checks if scenario or outline matches specified filter. 24 | * 25 | * @param ScenarioInterface $scenario Scenario or Outline node instance 26 | * 27 | * @return bool 28 | */ 29 | public function isScenarioMatch(ScenarioInterface $scenario); 30 | } 31 | -------------------------------------------------------------------------------- /src/Node/TaggedNodeTrait.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | /** 14 | * This trait partially implements {@see TaggedNodeInterface}. 15 | * 16 | * @internal 17 | */ 18 | trait TaggedNodeTrait 19 | { 20 | /** 21 | * @return list 22 | */ 23 | abstract public function getTags(); 24 | 25 | /** 26 | * @return bool 27 | */ 28 | public function hasTag(string $tag) 29 | { 30 | return in_array($tag, $this->getTags(), true); 31 | } 32 | 33 | /** 34 | * @return bool 35 | */ 36 | public function hasTags() 37 | { 38 | return $this->getTags() !== []; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Filter/ComplexFilterInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Filter; 12 | 13 | use Behat\Gherkin\Node\FeatureNode; 14 | use Behat\Gherkin\Node\ScenarioInterface; 15 | 16 | /** 17 | * Filter interface. 18 | * 19 | * @author Konstantin Kudryashov 20 | */ 21 | interface ComplexFilterInterface extends FeatureFilterInterface 22 | { 23 | /** 24 | * Checks if scenario or outline matches specified filter. 25 | * 26 | * @param FeatureNode $feature Feature node instance 27 | * @param ScenarioInterface $scenario Scenario or Outline node instance 28 | * 29 | * @return bool 30 | */ 31 | public function isScenarioMatch(FeatureNode $feature, ScenarioInterface $scenario); 32 | } 33 | -------------------------------------------------------------------------------- /src/Filter/FeatureFilterInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Filter; 12 | 13 | use Behat\Gherkin\Node\FeatureNode; 14 | 15 | /** 16 | * Feature filter interface. 17 | * 18 | * @author Konstantin Kudryashov 19 | */ 20 | interface FeatureFilterInterface 21 | { 22 | /** 23 | * Checks if Feature matches specified filter. 24 | * 25 | * @param FeatureNode $feature Feature instance 26 | * 27 | * @return bool 28 | */ 29 | public function isFeatureMatch(FeatureNode $feature); 30 | 31 | /** 32 | * Filters feature according to the filter and returns new one. 33 | * 34 | * @return FeatureNode 35 | */ 36 | public function filterFeature(FeatureNode $feature); 37 | } 38 | -------------------------------------------------------------------------------- /src/Keywords/CachedArrayKeywords.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Keywords; 12 | 13 | /** 14 | * File initializable keywords holder. 15 | * 16 | * $keywords = new Behat\Gherkin\Keywords\CachedArrayKeywords($file); 17 | * 18 | * @author Konstantin Kudryashov 19 | */ 20 | class CachedArrayKeywords extends ArrayKeywords 21 | { 22 | public static function withDefaultKeywords(): self 23 | { 24 | return new self(__DIR__ . '/../../i18n.php'); 25 | } 26 | 27 | /** 28 | * Initializes holder with file. 29 | * 30 | * @param string $file Cached array path 31 | */ 32 | public function __construct(string $file) 33 | { 34 | // @phpstan-ignore argument.type 35 | parent::__construct(require $file); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Node/TaggedNodeInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | /** 14 | * Gherkin tagged node interface. 15 | * 16 | * @author Konstantin Kudryashov 17 | */ 18 | interface TaggedNodeInterface extends NodeInterface 19 | { 20 | /** 21 | * Checks if node is tagged with tag. 22 | * 23 | * @return bool 24 | */ 25 | public function hasTag(string $tag); 26 | 27 | /** 28 | * Checks if node has tags (including any inherited tags e.g. from feature). 29 | * 30 | * @return bool 31 | */ 32 | public function hasTags(); 33 | 34 | /** 35 | * Returns node tags (including any inherited tags e.g. from feature). 36 | * 37 | * @return list 38 | */ 39 | public function getTags(); 40 | } 41 | -------------------------------------------------------------------------------- /src/Filter/SimpleFilter.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Filter; 12 | 13 | use Behat\Gherkin\Node\FeatureNode; 14 | 15 | /** 16 | * Abstract filter class. 17 | * 18 | * @author Konstantin Kudryashov 19 | */ 20 | abstract class SimpleFilter implements FilterInterface 21 | { 22 | /** 23 | * Filters feature according to the filter. 24 | * 25 | * @return FeatureNode 26 | */ 27 | public function filterFeature(FeatureNode $feature) 28 | { 29 | if ($this->isFeatureMatch($feature)) { 30 | return $feature; 31 | } 32 | 33 | return $feature->withScenarios( 34 | array_filter( 35 | $feature->getScenarios(), 36 | $this->isScenarioMatch(...) 37 | ) 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/ParserInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin; 12 | 13 | use Behat\Gherkin\Exception\ParserException; 14 | use Behat\Gherkin\Node\FeatureNode; 15 | 16 | interface ParserInterface 17 | { 18 | /** 19 | * Parses a Gherkin document string and returns feature (or null when none found). 20 | * 21 | * @param string $input Gherkin string document 22 | * @param string|null $file File name 23 | * 24 | * @return FeatureNode|null 25 | * 26 | * @throws ParserException 27 | */ 28 | public function parse(string $input, ?string $file = null); 29 | 30 | /** 31 | * Parses a Gherkin file and returns feature (or null when none found). 32 | * 33 | * @throws ParserException 34 | */ 35 | public function parseFile(string $file): ?FeatureNode; 36 | } 37 | -------------------------------------------------------------------------------- /src/Filter/NarrativeFilter.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Filter; 12 | 13 | use Behat\Gherkin\Node\FeatureNode; 14 | use Behat\Gherkin\Node\ScenarioInterface; 15 | 16 | /** 17 | * Filters features by their narrative using regular expression. 18 | * 19 | * @author Konstantin Kudryashov 20 | */ 21 | class NarrativeFilter extends SimpleFilter 22 | { 23 | public function __construct( 24 | private readonly string $regex, 25 | ) { 26 | } 27 | 28 | public function isFeatureMatch(FeatureNode $feature) 29 | { 30 | return (bool) preg_match($this->regex, $feature->getDescription() ?? ''); 31 | } 32 | 33 | public function isScenarioMatch(ScenarioInterface $scenario) 34 | { 35 | // This filter does not apply to scenarios. 36 | return false; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Exception/UnexpectedParserNodeException.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Exception; 12 | 13 | use Behat\Gherkin\Node\NodeInterface; 14 | 15 | class UnexpectedParserNodeException extends ParserException 16 | { 17 | public function __construct( 18 | public readonly string $expectation, 19 | public readonly string|NodeInterface $node, 20 | public readonly ?string $sourceFile, 21 | ) { 22 | parent::__construct( 23 | sprintf( 24 | 'Expected %s, but got %s%s', 25 | $expectation, 26 | is_string($node) 27 | ? "text: \"{$node}\"" 28 | : "{$node->getNodeType()} on line: {$node->getLine()}", 29 | $sourceFile ? " in file: {$sourceFile}" : '' 30 | ), 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2013 Konstantin Kudryashov 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/Filter/ComplexFilter.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Filter; 12 | 13 | use Behat\Gherkin\Node\FeatureNode; 14 | use Behat\Gherkin\Node\ScenarioInterface; 15 | 16 | /** 17 | * Abstract filter class. 18 | * 19 | * @author Konstantin Kudryashov 20 | */ 21 | abstract class ComplexFilter implements ComplexFilterInterface 22 | { 23 | /** 24 | * Filters feature according to the filter. 25 | * 26 | * @return FeatureNode 27 | */ 28 | public function filterFeature(FeatureNode $feature) 29 | { 30 | $scenarios = $feature->getScenarios(); 31 | $filteredScenarios = array_filter( 32 | $scenarios, 33 | fn (ScenarioInterface $scenario) => $this->isScenarioMatch($feature, $scenario) 34 | ); 35 | 36 | return $scenarios === $filteredScenarios ? $feature : $feature->withScenarios($filteredScenarios); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Loader/AbstractLoader.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Loader; 12 | 13 | use Behat\Gherkin\Node\FeatureNode; 14 | 15 | /** 16 | * @template TResourceType 17 | * 18 | * @implements LoaderInterface 19 | */ 20 | abstract class AbstractLoader implements LoaderInterface 21 | { 22 | public function load(mixed $resource) 23 | { 24 | if (!$this->supports($resource)) { 25 | throw new \LogicException(sprintf( 26 | '%s::%s() was called with unsupported resource `%s`.', 27 | static::class, 28 | __FUNCTION__, 29 | json_encode($resource) 30 | )); 31 | } 32 | 33 | return $this->doLoad($resource); 34 | } 35 | 36 | /** 37 | * @param TResourceType $resource 38 | * 39 | * @return list 40 | */ 41 | abstract protected function doLoad(mixed $resource): array; 42 | } 43 | -------------------------------------------------------------------------------- /src/Loader/LoaderInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Loader; 12 | 13 | use Behat\Gherkin\Node\FeatureNode; 14 | 15 | /** 16 | * Loader interface. 17 | * 18 | * @author Konstantin Kudryashov 19 | * 20 | * @template TResourceType 21 | */ 22 | interface LoaderInterface 23 | { 24 | /** 25 | * Checks if current loader supports provided resource. 26 | * 27 | * @template TSupportedResourceType 28 | * 29 | * @param TSupportedResourceType $resource Resource to load 30 | * 31 | * @phpstan-assert-if-true =LoaderInterface $this 32 | * 33 | * @return bool 34 | */ 35 | public function supports(mixed $resource); 36 | 37 | /** 38 | * Loads features from provided resource. 39 | * 40 | * @param TResourceType $resource Resource to load 41 | * 42 | * @return list 43 | */ 44 | public function load(mixed $resource); 45 | } 46 | -------------------------------------------------------------------------------- /src/Cache/CacheInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Cache; 12 | 13 | use Behat\Gherkin\Node\FeatureNode; 14 | 15 | /** 16 | * Parser cache interface. 17 | * 18 | * @author Konstantin Kudryashov 19 | */ 20 | interface CacheInterface 21 | { 22 | /** 23 | * Checks that cache for feature exists and is fresh. 24 | * 25 | * @param string $path Feature path 26 | * @param int $timestamp The last time feature was updated 27 | * 28 | * @return bool 29 | */ 30 | public function isFresh(string $path, int $timestamp); 31 | 32 | /** 33 | * Reads feature cache from path. 34 | * 35 | * @param string $path Feature path 36 | * 37 | * @return FeatureNode 38 | */ 39 | public function read(string $path); 40 | 41 | /** 42 | * Caches feature node. 43 | * 44 | * @param string $path Feature path 45 | * 46 | * @return void 47 | */ 48 | public function write(string $path, FeatureNode $feature); 49 | } 50 | -------------------------------------------------------------------------------- /src/Exception/UnexpectedTaggedNodeException.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Exception; 12 | 13 | use Behat\Gherkin\Lexer; 14 | 15 | /** 16 | * @phpstan-import-type TToken from Lexer 17 | */ 18 | class UnexpectedTaggedNodeException extends ParserException 19 | { 20 | /** 21 | * @phpstan-param TToken $taggedToken 22 | */ 23 | public function __construct( 24 | public readonly array $taggedToken, 25 | public readonly ?string $sourceFile, 26 | ) { 27 | $msg = match ($this->taggedToken['type']) { 28 | 'EOS' => 'Unexpected end of file after tags', 29 | default => sprintf( 30 | '%s can not be tagged, but it is', 31 | $taggedToken['type'], 32 | ), 33 | }; 34 | 35 | parent::__construct( 36 | sprintf( 37 | '%s on line: %d%s', 38 | $msg, 39 | $taggedToken['line'], 40 | $this->sourceFile ? " in file: {$this->sourceFile}" : '', 41 | ), 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Filter/RoleFilter.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Filter; 12 | 13 | use Behat\Gherkin\Node\FeatureNode; 14 | use Behat\Gherkin\Node\ScenarioInterface; 15 | 16 | /** 17 | * Filters features by their actors role. 18 | * 19 | * @author Konstantin Kudryashov 20 | */ 21 | class RoleFilter extends SimpleFilter 22 | { 23 | /** 24 | * @var string 25 | */ 26 | protected $pattern; 27 | 28 | /** 29 | * Initializes filter. 30 | * 31 | * @param string $role Approved role wildcard 32 | */ 33 | public function __construct(string $role) 34 | { 35 | $this->pattern = sprintf( 36 | '/as an? %s[$\n]/i', 37 | strtr( 38 | preg_quote($role, '/'), 39 | [ 40 | '\*' => '.*', 41 | '\?' => '.', 42 | '\[' => '[', 43 | '\]' => ']', 44 | ] 45 | ) 46 | ); 47 | } 48 | 49 | public function isFeatureMatch(FeatureNode $feature) 50 | { 51 | return (bool) preg_match($this->pattern, $feature->getDescription() ?? ''); 52 | } 53 | 54 | public function isScenarioMatch(ScenarioInterface $scenario) 55 | { 56 | // This filter does not apply to scenarios. 57 | return false; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Cache/MemoryCache.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Cache; 12 | 13 | use Behat\Gherkin\Node\FeatureNode; 14 | 15 | /** 16 | * Memory cache. 17 | * Caches feature into a memory. 18 | * 19 | * @author Konstantin Kudryashov 20 | */ 21 | class MemoryCache implements CacheInterface 22 | { 23 | /** 24 | * @var array 25 | */ 26 | private array $features = []; 27 | /** 28 | * @var array 29 | */ 30 | private array $timestamps = []; 31 | 32 | /** 33 | * Checks that cache for feature exists and is fresh. 34 | * 35 | * @param string $path Feature path 36 | * @param int $timestamp The last time feature was updated 37 | * 38 | * @return bool 39 | */ 40 | public function isFresh(string $path, int $timestamp) 41 | { 42 | if (!isset($this->features[$path])) { 43 | return false; 44 | } 45 | 46 | return $this->timestamps[$path] > $timestamp; 47 | } 48 | 49 | /** 50 | * Reads feature cache from path. 51 | * 52 | * @param string $path Feature path 53 | * 54 | * @return FeatureNode 55 | */ 56 | public function read(string $path) 57 | { 58 | return $this->features[$path]; 59 | } 60 | 61 | /** 62 | * Caches feature node. 63 | * 64 | * @param string $path Feature path 65 | * 66 | * @return void 67 | */ 68 | public function write(string $path, FeatureNode $feature) 69 | { 70 | $this->features[$path] = $feature; 71 | $this->timestamps[$path] = time(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Node/PyStringNode.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | use Stringable; 14 | 15 | /** 16 | * Represents Gherkin PyString argument. 17 | * 18 | * @author Konstantin Kudryashov 19 | * 20 | * @final since 4.15.0 21 | */ 22 | class PyStringNode implements Stringable, ArgumentInterface 23 | { 24 | /** 25 | * @param list $strings String in form of [$stringLine] 26 | * @param int $line Line number where string been started 27 | */ 28 | public function __construct( 29 | private readonly array $strings, 30 | private readonly int $line, 31 | ) { 32 | } 33 | 34 | /** 35 | * Returns node type. 36 | * 37 | * @return string 38 | */ 39 | public function getNodeType() 40 | { 41 | return 'PyString'; 42 | } 43 | 44 | /** 45 | * Returns entire PyString lines set. 46 | * 47 | * @return list 48 | */ 49 | public function getStrings() 50 | { 51 | return $this->strings; 52 | } 53 | 54 | /** 55 | * Returns raw string. 56 | * 57 | * @return string 58 | */ 59 | public function getRaw() 60 | { 61 | return implode("\n", $this->strings); 62 | } 63 | 64 | /** 65 | * Converts PyString into string. 66 | * 67 | * @return string 68 | */ 69 | public function __toString() 70 | { 71 | return $this->getRaw(); 72 | } 73 | 74 | /** 75 | * Returns line number at which PyString was started. 76 | * 77 | * @return int 78 | */ 79 | public function getLine() 80 | { 81 | return $this->line; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Dialect/CucumberDialectProvider.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Dialect; 12 | 13 | use Behat\Gherkin\Exception\NoSuchLanguageException; 14 | use Behat\Gherkin\Filesystem; 15 | 16 | /** 17 | * A dialect provider that loads the dialects based on the gherkin-languages.json file copied from the Cucumber project. 18 | * 19 | * @phpstan-import-type TDialectData from GherkinDialect 20 | */ 21 | final class CucumberDialectProvider implements DialectProviderInterface 22 | { 23 | /** 24 | * @var non-empty-array 25 | */ 26 | private readonly array $dialects; 27 | 28 | public function __construct() 29 | { 30 | /** 31 | * Here we force the type checker to assume the decoded JSON has the correct 32 | * structure, rather than validating it. This is safe because it's not dynamic. 33 | * 34 | * @var non-empty-array $data 35 | */ 36 | $data = Filesystem::readJsonFileHash(__DIR__ . '/../../resources/gherkin-languages.json'); 37 | $this->dialects = $data; 38 | } 39 | 40 | /** 41 | * @param non-empty-string $language 42 | * 43 | * @throws NoSuchLanguageException 44 | */ 45 | public function getDialect(string $language): GherkinDialect 46 | { 47 | if (!isset($this->dialects[$language])) { 48 | throw new NoSuchLanguageException($language); 49 | } 50 | 51 | return new GherkinDialect($language, $this->dialects[$language]); 52 | } 53 | 54 | public function getDefaultDialect(): GherkinDialect 55 | { 56 | return $this->getDialect('en'); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Loader/DirectoryLoader.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Loader; 12 | 13 | use Behat\Gherkin\Gherkin; 14 | use RecursiveDirectoryIterator; 15 | use RecursiveIteratorIterator; 16 | use SplFileInfo; 17 | use Traversable; 18 | 19 | /** 20 | * Directory contents loader. 21 | * 22 | * @author Konstantin Kudryashov 23 | * 24 | * @extends AbstractFileLoader 25 | */ 26 | class DirectoryLoader extends AbstractFileLoader 27 | { 28 | /** 29 | * @var Gherkin 30 | */ 31 | protected $gherkin; 32 | 33 | /** 34 | * Initializes loader. 35 | */ 36 | public function __construct(Gherkin $gherkin) 37 | { 38 | $this->gherkin = $gherkin; 39 | } 40 | 41 | public function supports(mixed $resource) 42 | { 43 | return is_string($resource) 44 | && ($path = $this->findAbsolutePath($resource)) !== false 45 | && is_dir($path); 46 | } 47 | 48 | protected function doLoad(mixed $resource): array 49 | { 50 | $path = $this->getAbsolutePath($resource); 51 | /** @var Traversable $iterator */ 52 | $iterator = new RecursiveIteratorIterator( 53 | new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS) 54 | ); 55 | $paths = array_map(strval(...), iterator_to_array($iterator)); 56 | uasort($paths, strnatcasecmp(...)); 57 | 58 | $features = []; 59 | 60 | foreach ($paths as $path) { 61 | $path = (string) $path; 62 | $loader = $this->gherkin->resolveLoader($path); 63 | 64 | if ($loader !== null) { 65 | array_push($features, ...$loader->load($path)); 66 | } 67 | } 68 | 69 | return $features; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Loader/YamlFileLoader.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Loader; 12 | 13 | use Behat\Gherkin\Node\FeatureNode; 14 | use Symfony\Component\Yaml\Yaml; 15 | 16 | /** 17 | * Yaml files loader. 18 | * 19 | * @author Konstantin Kudryashov 20 | * 21 | * @extends AbstractFileLoader 22 | * 23 | * @phpstan-import-type TArrayResource from ArrayLoader 24 | */ 25 | class YamlFileLoader extends AbstractFileLoader 26 | { 27 | /** 28 | * @phpstan-param LoaderInterface $loader 29 | */ 30 | public function __construct( 31 | private readonly LoaderInterface $loader = new ArrayLoader(), 32 | ) { 33 | } 34 | 35 | public function supports(mixed $resource) 36 | { 37 | return is_string($resource) 38 | && ($path = $this->findAbsolutePath($resource)) !== false 39 | && is_file($path) 40 | && pathinfo($path, PATHINFO_EXTENSION) === 'yml'; 41 | } 42 | 43 | protected function doLoad(mixed $resource): array 44 | { 45 | $path = $this->getAbsolutePath($resource); 46 | $hash = Yaml::parseFile($path); 47 | 48 | // @phpstan-ignore argument.type 49 | $features = $this->loader->load($hash); 50 | 51 | return array_map( 52 | static fn (FeatureNode $feature) => new FeatureNode( 53 | $feature->getTitle(), 54 | $feature->getDescription(), 55 | $feature->getTags(), 56 | $feature->getBackground(), 57 | $feature->getScenarios(), 58 | $feature->getKeyword(), 59 | $feature->getLanguage(), 60 | $path, 61 | $feature->getLine() 62 | ), 63 | $features 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Filter/PathsFilter.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Filter; 12 | 13 | use Behat\Gherkin\Exception\FilesystemException; 14 | use Behat\Gherkin\Filesystem; 15 | use Behat\Gherkin\Node\FeatureNode; 16 | use Behat\Gherkin\Node\ScenarioInterface; 17 | 18 | /** 19 | * Filters features by their paths. 20 | * 21 | * @author Konstantin Kudryashov 22 | */ 23 | class PathsFilter extends SimpleFilter 24 | { 25 | /** 26 | * @var list 27 | */ 28 | protected $filterPaths = []; 29 | 30 | /** 31 | * Initializes filter. 32 | * 33 | * @param array $paths List of approved paths 34 | */ 35 | public function __construct(array $paths) 36 | { 37 | foreach ($paths as $path) { 38 | try { 39 | $realpath = Filesystem::getRealPath($path); 40 | } catch (FilesystemException) { 41 | continue; 42 | } 43 | 44 | $this->filterPaths[] = rtrim($realpath, DIRECTORY_SEPARATOR) 45 | . (is_dir($realpath) ? DIRECTORY_SEPARATOR : ''); 46 | } 47 | } 48 | 49 | public function isFeatureMatch(FeatureNode $feature) 50 | { 51 | if (($filePath = $feature->getFile()) === null) { 52 | return false; 53 | } 54 | 55 | $realFeatureFilePath = Filesystem::getRealPath($filePath); 56 | 57 | foreach ($this->filterPaths as $filterPath) { 58 | if (str_starts_with($realFeatureFilePath, $filterPath)) { 59 | return true; 60 | } 61 | } 62 | 63 | return false; 64 | } 65 | 66 | public function isScenarioMatch(ScenarioInterface $scenario) 67 | { 68 | // This filter does not apply to scenarios. 69 | return false; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Node/ExampleTableNode.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | /** 14 | * Represents Gherkin Outline Example Table. 15 | * 16 | * @author Konstantin Kudryashov 17 | * 18 | * @final since 4.15.0 19 | */ 20 | class ExampleTableNode extends TableNode implements TaggedNodeInterface, DescribableNodeInterface 21 | { 22 | use TaggedNodeTrait; 23 | 24 | /** 25 | * @param array> $table Table in form of [$rowLineNumber => [$val1, $val2, $val3]] 26 | * @param list $tags 27 | */ 28 | public function __construct( 29 | array $table, 30 | private readonly string $keyword, 31 | private readonly array $tags = [], 32 | private readonly ?string $name = null, 33 | private readonly ?string $description = null, 34 | ) { 35 | parent::__construct($table); 36 | } 37 | 38 | /** 39 | * Returns node type string. 40 | * 41 | * @return string 42 | */ 43 | public function getNodeType() 44 | { 45 | return 'ExampleTable'; 46 | } 47 | 48 | public function getName(): ?string 49 | { 50 | return $this->name; 51 | } 52 | 53 | public function getDescription(): ?string 54 | { 55 | return $this->description; 56 | } 57 | 58 | public function getTags() 59 | { 60 | return $this->tags; 61 | } 62 | 63 | /** 64 | * Returns example table keyword. 65 | * 66 | * @return string 67 | */ 68 | public function getKeyword() 69 | { 70 | return $this->keyword; 71 | } 72 | 73 | /** 74 | * @param array> $table Table in form of [$rowLineNumber => [$val1, $val2, $val3]] 75 | */ 76 | public function withTable(array $table): self 77 | { 78 | return new self( 79 | $table, 80 | $this->keyword, 81 | $this->tags, 82 | $this->name, 83 | $this->description, 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Node/BackgroundNode.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | /** 14 | * Represents Gherkin Background. 15 | * 16 | * @author Konstantin Kudryashov 17 | * 18 | * @final since 4.15.0 19 | */ 20 | class BackgroundNode implements ScenarioLikeInterface, DescribableNodeInterface 21 | { 22 | /** 23 | * @param StepNode[] $steps 24 | */ 25 | public function __construct( 26 | private readonly ?string $title, 27 | private readonly array $steps, 28 | private readonly string $keyword, 29 | private readonly int $line, 30 | private readonly ?string $description = null, 31 | ) { 32 | } 33 | 34 | /** 35 | * Returns node type string. 36 | * 37 | * @return string 38 | */ 39 | public function getNodeType() 40 | { 41 | return 'Background'; 42 | } 43 | 44 | /** 45 | * Returns background title. 46 | * 47 | * @return string|null 48 | */ 49 | public function getTitle() 50 | { 51 | return $this->title; 52 | } 53 | 54 | public function getDescription(): ?string 55 | { 56 | return $this->description; 57 | } 58 | 59 | /** 60 | * Checks if background has steps. 61 | * 62 | * @return bool 63 | */ 64 | public function hasSteps() 65 | { 66 | return (bool) count($this->steps); 67 | } 68 | 69 | /** 70 | * Returns background steps. 71 | * 72 | * @return StepNode[] 73 | */ 74 | public function getSteps() 75 | { 76 | return $this->steps; 77 | } 78 | 79 | /** 80 | * Returns background keyword. 81 | * 82 | * @return string 83 | */ 84 | public function getKeyword() 85 | { 86 | return $this->keyword; 87 | } 88 | 89 | /** 90 | * Returns background declaration line number. 91 | * 92 | * @return int 93 | */ 94 | public function getLine() 95 | { 96 | return $this->line; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Loader/GherkinFileLoader.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Loader; 12 | 13 | use Behat\Gherkin\Cache\CacheInterface; 14 | use Behat\Gherkin\Filesystem; 15 | use Behat\Gherkin\Node\FeatureNode; 16 | use Behat\Gherkin\ParserInterface; 17 | 18 | /** 19 | * Gherkin *.feature files loader. 20 | * 21 | * @author Konstantin Kudryashov 22 | * 23 | * @extends AbstractFileLoader 24 | */ 25 | class GherkinFileLoader extends AbstractFileLoader 26 | { 27 | /** 28 | * @var ParserInterface 29 | */ 30 | protected $parser; 31 | /** 32 | * @var CacheInterface|null 33 | */ 34 | protected $cache; 35 | 36 | public function __construct(ParserInterface $parser, ?CacheInterface $cache = null) 37 | { 38 | $this->parser = $parser; 39 | $this->cache = $cache; 40 | } 41 | 42 | /** 43 | * Sets cache layer. 44 | * 45 | * @return void 46 | */ 47 | public function setCache(CacheInterface $cache) 48 | { 49 | $this->cache = $cache; 50 | } 51 | 52 | public function supports(mixed $resource) 53 | { 54 | return is_string($resource) 55 | && ($path = $this->findAbsolutePath($resource)) !== false 56 | && is_file($path) 57 | && pathinfo($path, PATHINFO_EXTENSION) === 'feature'; 58 | } 59 | 60 | protected function doLoad(mixed $resource): array 61 | { 62 | $path = $this->getAbsolutePath($resource); 63 | if ($this->cache) { 64 | if ($this->cache->isFresh($path, Filesystem::getLastModified($path))) { 65 | $feature = $this->cache->read($path); 66 | } elseif (null !== $feature = $this->parseFeature($path)) { 67 | $this->cache->write($path, $feature); 68 | } 69 | } else { 70 | $feature = $this->parseFeature($path); 71 | } 72 | 73 | return $feature !== null ? [$feature] : []; 74 | } 75 | 76 | /** 77 | * Parses feature at provided absolute path. 78 | * 79 | * @param string $path Feature path 80 | * 81 | * @return FeatureNode|null 82 | */ 83 | protected function parseFeature(string $path) 84 | { 85 | return $this->parser->parseFile($path); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Keywords/KeywordsInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Keywords; 12 | 13 | /** 14 | * Keywords holder interface. 15 | * 16 | * @author Konstantin Kudryashov 17 | */ 18 | interface KeywordsInterface 19 | { 20 | /** 21 | * Sets keywords holder language. 22 | * 23 | * @return void 24 | */ 25 | public function setLanguage(string $language); 26 | 27 | /** 28 | * Returns Feature keywords (separated by "|"). 29 | * 30 | * @return string 31 | */ 32 | public function getFeatureKeywords(); 33 | 34 | /** 35 | * Returns Background keywords (separated by "|"). 36 | * 37 | * @return string 38 | */ 39 | public function getBackgroundKeywords(); 40 | 41 | /** 42 | * Returns Scenario keywords (separated by "|"). 43 | * 44 | * @return string 45 | */ 46 | public function getScenarioKeywords(); 47 | 48 | /** 49 | * Returns Scenario Outline keywords (separated by "|"). 50 | * 51 | * @return string 52 | */ 53 | public function getOutlineKeywords(); 54 | 55 | /** 56 | * Returns Examples keywords (separated by "|"). 57 | * 58 | * @return string 59 | */ 60 | public function getExamplesKeywords(); 61 | 62 | /** 63 | * Returns Given keywords (separated by "|"). 64 | * 65 | * @return string 66 | */ 67 | public function getGivenKeywords(); 68 | 69 | /** 70 | * Returns When keywords (separated by "|"). 71 | * 72 | * @return string 73 | */ 74 | public function getWhenKeywords(); 75 | 76 | /** 77 | * Returns Then keywords (separated by "|"). 78 | * 79 | * @return string 80 | */ 81 | public function getThenKeywords(); 82 | 83 | /** 84 | * Returns And keywords (separated by "|"). 85 | * 86 | * @return string 87 | */ 88 | public function getAndKeywords(); 89 | 90 | /** 91 | * Returns But keywords (separated by "|"). 92 | * 93 | * @return string 94 | */ 95 | public function getButKeywords(); 96 | 97 | /** 98 | * Returns all step keywords (separated by "|"). 99 | * 100 | * @return string 101 | */ 102 | public function getStepKeywords(); 103 | } 104 | -------------------------------------------------------------------------------- /src/Loader/AbstractFileLoader.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Loader; 12 | 13 | use Behat\Gherkin\Filesystem; 14 | 15 | /** 16 | * Abstract filesystem loader. 17 | * 18 | * @author Konstantin Kudryashov 19 | * 20 | * @template TResourceType 21 | * 22 | * @extends AbstractLoader 23 | * 24 | * @implements FileLoaderInterface 25 | */ 26 | abstract class AbstractFileLoader extends AbstractLoader implements FileLoaderInterface 27 | { 28 | /** 29 | * @var string|null 30 | */ 31 | protected $basePath; 32 | 33 | /** 34 | * Sets base features path. 35 | * 36 | * @return void 37 | */ 38 | public function setBasePath(string $path) 39 | { 40 | $this->basePath = Filesystem::getRealPath($path); 41 | } 42 | 43 | /** 44 | * Finds relative path for provided absolute (relative to base features path). 45 | * 46 | * @param string $path Absolute path 47 | * 48 | * @return string 49 | */ 50 | protected function findRelativePath(string $path) 51 | { 52 | if ($this->basePath !== null) { 53 | return strtr($path, [$this->basePath . DIRECTORY_SEPARATOR => '']); 54 | } 55 | 56 | return $path; 57 | } 58 | 59 | /** 60 | * Finds absolute path for provided relative (relative to base features path). 61 | * 62 | * @param string $path Relative path 63 | * 64 | * @return false|string 65 | */ 66 | protected function findAbsolutePath(string $path) 67 | { 68 | if (file_exists($path)) { 69 | return realpath($path); 70 | } 71 | 72 | if ($this->basePath === null) { 73 | return false; 74 | } 75 | 76 | if (file_exists($this->basePath . DIRECTORY_SEPARATOR . $path)) { 77 | return realpath($this->basePath . DIRECTORY_SEPARATOR . $path); 78 | } 79 | 80 | return false; 81 | } 82 | 83 | /** 84 | * @throws \RuntimeException 85 | */ 86 | final protected function getAbsolutePath(string $path): string 87 | { 88 | $resolvedPath = $this->findAbsolutePath($path); 89 | if ($resolvedPath === false) { 90 | throw new \RuntimeException("Unable to locate absolute path of \"$path\""); 91 | } 92 | 93 | return $resolvedPath; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Filter/NameFilter.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Filter; 12 | 13 | use Behat\Gherkin\Node\DescribableNodeInterface; 14 | use Behat\Gherkin\Node\FeatureNode; 15 | use Behat\Gherkin\Node\ScenarioInterface; 16 | 17 | /** 18 | * Filters scenarios by feature/scenario name. 19 | * 20 | * @author Konstantin Kudryashov 21 | */ 22 | class NameFilter extends SimpleFilter 23 | { 24 | /** 25 | * @var string 26 | */ 27 | protected $filterString; 28 | 29 | public function __construct(string $filterString) 30 | { 31 | $this->filterString = trim($filterString); 32 | } 33 | 34 | /** 35 | * Checks if Feature matches specified filter. 36 | * 37 | * @param FeatureNode $feature Feature instance 38 | * 39 | * @return bool 40 | */ 41 | public function isFeatureMatch(FeatureNode $feature) 42 | { 43 | if ($feature->getTitle() === null) { 44 | return false; 45 | } 46 | 47 | if ($this->filterString[0] === '/') { 48 | return (bool) preg_match($this->filterString, $feature->getTitle()); 49 | } 50 | 51 | return str_contains($feature->getTitle(), $this->filterString); 52 | } 53 | 54 | /** 55 | * Checks if scenario or outline matches specified filter. 56 | * 57 | * @param ScenarioInterface $scenario Scenario or Outline node instance 58 | * 59 | * @return bool 60 | */ 61 | public function isScenarioMatch(ScenarioInterface $scenario) 62 | { 63 | // Historically (and in legacy GherkinCompatibilityMode), multiline scenario text was all part of the title. 64 | // In new GherkinCompatibilityMode the text will be split into a single-line title & multiline description. 65 | // For BC, this filter should continue to match on the complete multiline text value. 66 | $textParts = array_filter([ 67 | $scenario->getTitle(), 68 | $scenario instanceof DescribableNodeInterface ? $scenario->getDescription() : null, 69 | ]); 70 | 71 | if ($textParts === []) { 72 | return false; 73 | } 74 | 75 | $textToMatch = implode("\n", $textParts); 76 | 77 | if ($this->filterString[0] === '/' && preg_match($this->filterString, $textToMatch)) { 78 | return true; 79 | } 80 | 81 | if (str_contains($textToMatch, $this->filterString)) { 82 | return true; 83 | } 84 | 85 | return false; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Node/ScenarioNode.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | /** 14 | * Represents Gherkin Scenario. 15 | * 16 | * @author Konstantin Kudryashov 17 | * 18 | * @final since 4.15.0 19 | */ 20 | class ScenarioNode implements ScenarioInterface, NamedScenarioInterface, DescribableNodeInterface 21 | { 22 | use TaggedNodeTrait; 23 | 24 | /** 25 | * @param StepNode[] $steps 26 | * @param list $tags 27 | */ 28 | public function __construct( 29 | private readonly ?string $title, 30 | private readonly array $tags, 31 | private readonly array $steps, 32 | private readonly string $keyword, 33 | private readonly int $line, 34 | private readonly ?string $description = null, 35 | ) { 36 | } 37 | 38 | /** 39 | * Returns node type string. 40 | * 41 | * @return string 42 | */ 43 | public function getNodeType() 44 | { 45 | return 'Scenario'; 46 | } 47 | 48 | /** 49 | * Returns scenario title. 50 | * 51 | * @return string|null 52 | * 53 | * @deprecated you should use {@see self::getName()} instead as this method will be removed in the next 54 | * major version 55 | */ 56 | public function getTitle() 57 | { 58 | return $this->title; 59 | } 60 | 61 | public function getName(): ?string 62 | { 63 | return $this->title; 64 | } 65 | 66 | public function getDescription(): ?string 67 | { 68 | return $this->description; 69 | } 70 | 71 | public function getTags() 72 | { 73 | return $this->tags; 74 | } 75 | 76 | /** 77 | * Checks if scenario has steps. 78 | * 79 | * @return bool 80 | */ 81 | public function hasSteps() 82 | { 83 | return count($this->steps) > 0; 84 | } 85 | 86 | /** 87 | * Returns scenario steps. 88 | * 89 | * @return StepNode[] 90 | */ 91 | public function getSteps() 92 | { 93 | return $this->steps; 94 | } 95 | 96 | /** 97 | * Returns scenario keyword. 98 | * 99 | * @return string 100 | */ 101 | public function getKeyword() 102 | { 103 | return $this->keyword; 104 | } 105 | 106 | /** 107 | * Returns scenario declaration line number. 108 | * 109 | * @return int 110 | */ 111 | public function getLine() 112 | { 113 | return $this->line; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "behat/gherkin", 3 | "description": "Gherkin DSL parser for PHP", 4 | "keywords": ["BDD", "parser", "DSL", "Behat", "Gherkin", "Cucumber"], 5 | "homepage": "https://behat.org/", 6 | "type": "library", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Konstantin Kudryashov", 11 | "email": "ever.zet@gmail.com", 12 | "homepage": "https://everzet.com" 13 | } 14 | ], 15 | 16 | "require": { 17 | "php": ">=8.1 <8.6", 18 | "composer-runtime-api": "^2.2" 19 | }, 20 | 21 | "require-dev": { 22 | "symfony/yaml": "^5.4 || ^6.4 || ^7.0", 23 | "phpunit/phpunit": "^10.5", 24 | "cucumber/gherkin-monorepo": "dev-gherkin-v37.0.1", 25 | "friendsofphp/php-cs-fixer": "^3.77", 26 | "phpstan/phpstan": "^2", 27 | "phpstan/extension-installer": "^1", 28 | "phpstan/phpstan-phpunit": "^2", 29 | "mikey179/vfsstream": "^1.6" 30 | }, 31 | 32 | "suggest": { 33 | "symfony/yaml": "If you want to parse features, represented in YAML files" 34 | }, 35 | 36 | "autoload": { 37 | "psr-4": { 38 | "Behat\\Gherkin\\": "src/" 39 | } 40 | }, 41 | 42 | "autoload-dev": { 43 | "psr-4": { 44 | "Tests\\Behat\\Gherkin\\": "tests/" 45 | } 46 | }, 47 | 48 | "extra": { 49 | "branch-alias": { 50 | "dev-master": "4.x-dev" 51 | } 52 | }, 53 | 54 | "repositories": [ 55 | { 56 | "type": "package", 57 | "package": { 58 | "name": "cucumber/gherkin-monorepo", 59 | "version": "dev-gherkin-v37.0.1", 60 | "source": { 61 | "type": "git", 62 | "url": "https://github.com/cucumber/gherkin.git", 63 | "reference": "501f8ae664867f30d9b0948adf6943bb8e443237" 64 | }, 65 | "dist": { 66 | "type": "zip", 67 | "url": "https://api.github.com/repos/cucumber/gherkin/zipball/501f8ae664867f30d9b0948adf6943bb8e443237", 68 | "reference": "501f8ae664867f30d9b0948adf6943bb8e443237" 69 | } 70 | } 71 | } 72 | ], 73 | 74 | "scripts": { 75 | "lint": [ 76 | "phpstan analyze --ansi --no-progress --memory-limit=-1", 77 | "phpstan analyze bin/update_cucumber --ansi --no-progress --memory-limit=-1", 78 | "phpstan analyze bin/update_i18n --ansi --no-progress --memory-limit=-1", 79 | "php-cs-fixer check --diff --ansi --show-progress=dots --verbose" 80 | ], 81 | "test": [ 82 | "phpunit --colors=always" 83 | ], 84 | "fix": [ 85 | "php-cs-fixer fix --diff --ansi --show-progress=dots" 86 | ] 87 | }, 88 | 89 | "config": { 90 | "process-timeout": 0, 91 | "allow-plugins": { 92 | "phpstan/extension-installer": true 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Keywords/CucumberKeywords.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Keywords; 12 | 13 | use Symfony\Component\Yaml\Exception\ParseException; 14 | use Symfony\Component\Yaml\Yaml; 15 | 16 | /** 17 | * Cucumber-translations reader. 18 | * 19 | * $keywords = new Behat\Gherkin\Keywords\CucumberKeywords($i18nYmlPath); 20 | * 21 | * @author Konstantin Kudryashov 22 | */ 23 | class CucumberKeywords extends ArrayKeywords 24 | { 25 | /** 26 | * Initializes holder with yaml string OR file. 27 | * 28 | * @param string $yaml Yaml string or file path 29 | */ 30 | public function __construct(string $yaml) 31 | { 32 | if (!str_contains($yaml, "\n") && is_file($yaml)) { 33 | $content = Yaml::parseFile($yaml); 34 | } else { 35 | $content = Yaml::parse($yaml); 36 | } 37 | 38 | if (!is_array($content)) { 39 | throw new ParseException(sprintf('Root element must be an array, but %s found.', get_debug_type($content))); 40 | } 41 | 42 | // @phpstan-ignore argument.type 43 | parent::__construct($content); 44 | } 45 | 46 | /** 47 | * Returns Feature keywords (separated by "|"). 48 | * 49 | * @return string 50 | */ 51 | public function getGivenKeywords() 52 | { 53 | return $this->prepareStepString(parent::getGivenKeywords()); 54 | } 55 | 56 | /** 57 | * Returns When keywords (separated by "|"). 58 | * 59 | * @return string 60 | */ 61 | public function getWhenKeywords() 62 | { 63 | return $this->prepareStepString(parent::getWhenKeywords()); 64 | } 65 | 66 | /** 67 | * Returns Then keywords (separated by "|"). 68 | * 69 | * @return string 70 | */ 71 | public function getThenKeywords() 72 | { 73 | return $this->prepareStepString(parent::getThenKeywords()); 74 | } 75 | 76 | /** 77 | * Returns And keywords (separated by "|"). 78 | * 79 | * @return string 80 | */ 81 | public function getAndKeywords() 82 | { 83 | return $this->prepareStepString(parent::getAndKeywords()); 84 | } 85 | 86 | /** 87 | * Returns But keywords (separated by "|"). 88 | * 89 | * @return string 90 | */ 91 | public function getButKeywords() 92 | { 93 | return $this->prepareStepString(parent::getButKeywords()); 94 | } 95 | 96 | /** 97 | * Trim *| from the beginning of the list. 98 | */ 99 | private function prepareStepString(string $keywordsString): string 100 | { 101 | if (str_starts_with($keywordsString, '*|')) { 102 | $keywordsString = mb_substr($keywordsString, 2, mb_strlen($keywordsString, 'utf8') - 2, 'utf8'); 103 | } 104 | 105 | return $keywordsString; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Node/StepNode.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | use Behat\Gherkin\Exception\NodeException; 14 | 15 | /** 16 | * Represents Gherkin Step. 17 | * 18 | * @author Konstantin Kudryashov 19 | * 20 | * @final since 4.15.0 21 | */ 22 | class StepNode implements NodeInterface 23 | { 24 | private readonly string $keywordType; 25 | 26 | /** 27 | * @param ArgumentInterface[] $arguments 28 | */ 29 | public function __construct( 30 | private readonly string $keyword, 31 | private readonly string $text, 32 | private readonly array $arguments, 33 | private readonly int $line, 34 | ?string $keywordType = null, 35 | ) { 36 | if (count($arguments) > 1) { 37 | throw new NodeException(sprintf( 38 | 'Steps could have only one argument, but `%s %s` have %d.', 39 | $keyword, 40 | $text, 41 | count($arguments) 42 | )); 43 | } 44 | 45 | $this->keywordType = $keywordType ?: 'Given'; 46 | } 47 | 48 | /** 49 | * Returns node type string. 50 | * 51 | * @return string 52 | */ 53 | public function getNodeType() 54 | { 55 | return 'Step'; 56 | } 57 | 58 | /** 59 | * Returns step keyword in provided language (Given, When, Then, etc.). 60 | * 61 | * @return string 62 | * 63 | * @deprecated use getKeyword() instead 64 | */ 65 | public function getType() 66 | { 67 | return $this->getKeyword(); 68 | } 69 | 70 | /** 71 | * Returns step keyword in provided language (Given, When, Then, etc.). 72 | * 73 | * @return string 74 | */ 75 | public function getKeyword() 76 | { 77 | return $this->keyword; 78 | } 79 | 80 | /** 81 | * Returns step type keyword (Given, When, Then, etc.). 82 | * 83 | * @return string 84 | */ 85 | public function getKeywordType() 86 | { 87 | return $this->keywordType; 88 | } 89 | 90 | /** 91 | * Returns step text. 92 | * 93 | * @return string 94 | */ 95 | public function getText() 96 | { 97 | return $this->text; 98 | } 99 | 100 | /** 101 | * Checks if step has arguments. 102 | * 103 | * @return bool 104 | */ 105 | public function hasArguments() 106 | { 107 | return (bool) count($this->arguments); 108 | } 109 | 110 | /** 111 | * Returns step arguments. 112 | * 113 | * @return ArgumentInterface[] 114 | */ 115 | public function getArguments() 116 | { 117 | return $this->arguments; 118 | } 119 | 120 | /** 121 | * Returns step declaration line number. 122 | * 123 | * @return int 124 | */ 125 | public function getLine() 126 | { 127 | return $this->line; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Behat Gherkin Parser 2 | 3 | This is the php Gherkin parser for Behat. It comes bundled with more than 40 native languages (see `i18n.php`) support 4 | and clean architecture. 5 | 6 | ## Useful Links 7 | 8 | - [Behat Site](https://behat.org) 9 | - [Note on Patches/Pull Requests](CONTRIBUTING.md) 10 | 11 | ## Usage Example 12 | 13 | ```php 14 | array( 18 | 'feature' => 'Feature', 19 | 'background' => 'Background', 20 | 'scenario' => 'Scenario', 21 | 'scenario_outline' => 'Scenario Outline|Scenario Template', 22 | 'examples' => 'Examples|Scenarios', 23 | 'given' => 'Given', 24 | 'when' => 'When', 25 | 'then' => 'Then', 26 | 'and' => 'And', 27 | 'but' => 'But' 28 | ), 29 | 'en-pirate' => array( 30 | 'feature' => 'Ahoy matey!', 31 | 'background' => 'Yo-ho-ho', 32 | 'scenario' => 'Heave to', 33 | 'scenario_outline' => 'Shiver me timbers', 34 | 'examples' => 'Dead men tell no tales', 35 | 'given' => 'Gangway!', 36 | 'when' => 'Blimey!', 37 | 'then' => 'Let go and haul', 38 | 'and' => 'Aye', 39 | 'but' => 'Avast!' 40 | ) 41 | )); 42 | $lexer = new Behat\Gherkin\Lexer($keywords); 43 | $parser = new Behat\Gherkin\Parser($lexer); 44 | 45 | $feature = $parser->parse(file_get_contents('some.feature')); 46 | ``` 47 | 48 | ## Installing Dependencies 49 | 50 | ```shell 51 | curl https://getcomposer.org/installer | php 52 | php composer.phar update 53 | ``` 54 | 55 | Contributors 56 | ------------ 57 | 58 | - Konstantin Kudryashov [everzet](https://github.com/everzet) [original developer] 59 | - Andrew Coulton [acoulton](https://github.com/acoulton) [current maintainer] 60 | - Carlos Granados [carlos-granados](https://github.com/carlos-granados) [current maintainer] 61 | - Christophe Coevoet [stof](https://github.com/stof) [current maintainer] 62 | - Other [awesome developers](https://github.com/Behat/Gherkin/graphs/contributors) 63 | 64 | Support the project 65 | ------------------- 66 | 67 | Behat is free software, maintained by volunteers as a gift for users. If you'd like to see 68 | the project continue to thrive, and particularly if you use it for work, we'd encourage you 69 | to contribute. 70 | 71 | Contributions of time - whether code, documentation, or support reviewing PRs and triaging 72 | issues - are very welcome and valued by the maintainers and the wider Behat community. 73 | 74 | But we also believe that [financial sponsorship is an important part of a healthy Open Source 75 | ecosystem](https://opensourcepledge.com/about/). Maintaining a project like Behat requires a 76 | significant commitment from the core team: your support will help us to keep making that time 77 | available over the long term. Even small contributions make a big difference. 78 | 79 | You can support [@acoulton](https://github.com/acoulton), [@carlos-granados](https://github.com/carlos-granados) and 80 | [@stof](https://github.com/stof) on GitHub sponsors. If you'd like to discuss supporting us in a different way, please 81 | get in touch! 82 | -------------------------------------------------------------------------------- /src/GherkinCompatibilityMode.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin; 12 | 13 | enum GherkinCompatibilityMode: string 14 | { 15 | case LEGACY = 'legacy'; 16 | 17 | /** 18 | * Note: The gherkin-32 parsing mode is not yet complete, and further behaviour changes are expected. 19 | * 20 | * @see https://github.com/Behat/Gherkin/issues?q=is%3Aissue%20state%3Aopen%20label%3Acucumber-parity 21 | */ 22 | case GHERKIN_32 = 'gherkin-32'; 23 | 24 | /** 25 | * @internal 26 | */ 27 | public function shouldRemoveStepKeywordSpace(): bool 28 | { 29 | return match ($this) { 30 | self::LEGACY => true, 31 | default => false, 32 | }; 33 | } 34 | 35 | /** 36 | * @internal 37 | */ 38 | public function shouldRemoveDescriptionPadding(): bool 39 | { 40 | return match ($this) { 41 | self::LEGACY => true, 42 | default => false, 43 | }; 44 | } 45 | 46 | /** 47 | * @internal 48 | */ 49 | public function allowAllNodeDescriptions(): bool 50 | { 51 | return match ($this) { 52 | self::LEGACY => false, 53 | default => true, 54 | }; 55 | } 56 | 57 | /** 58 | * @internal 59 | */ 60 | public function shouldUseNewTableCellParsing(): bool 61 | { 62 | return match ($this) { 63 | self::LEGACY => false, 64 | default => true, 65 | }; 66 | } 67 | 68 | /** 69 | * @internal 70 | */ 71 | public function shouldUnespaceDocStringDelimiters(): bool 72 | { 73 | return match ($this) { 74 | self::LEGACY => false, 75 | default => true, 76 | }; 77 | } 78 | 79 | /** 80 | * @internal 81 | */ 82 | public function shouldIgnoreInvalidLanguage(): bool 83 | { 84 | return match ($this) { 85 | self::LEGACY => true, 86 | default => false, 87 | }; 88 | } 89 | 90 | /** 91 | * @internal 92 | */ 93 | public function allowWhitespaceInLanguageTag(): bool 94 | { 95 | return match ($this) { 96 | self::LEGACY => false, 97 | default => true, 98 | }; 99 | } 100 | 101 | /** 102 | * @internal 103 | */ 104 | public function shouldRemoveTagPrefixChar(): bool 105 | { 106 | // Note: When this is removed we can also remove the code in TagFilter that handles tags with no leading @ 107 | return match ($this) { 108 | self::LEGACY => true, 109 | default => false, 110 | }; 111 | } 112 | 113 | /** 114 | * @internal 115 | */ 116 | public function shouldThrowOnWhitespaceInTag(): bool 117 | { 118 | return match ($this) { 119 | // Note, although we don't throw we have triggered an E_USER_DEPRECATED in Parser::guardTags since v4.9.0 120 | self::LEGACY => false, 121 | default => true, 122 | }; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Filter/LineFilter.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Filter; 12 | 13 | use Behat\Gherkin\Node\FeatureNode; 14 | use Behat\Gherkin\Node\OutlineNode; 15 | use Behat\Gherkin\Node\ScenarioInterface; 16 | 17 | /** 18 | * Filters scenarios by definition line number. 19 | * 20 | * @author Konstantin Kudryashov 21 | */ 22 | class LineFilter implements FilterInterface 23 | { 24 | /** 25 | * @var int 26 | */ 27 | protected $filterLine; 28 | 29 | /** 30 | * Initializes filter. 31 | * 32 | * @param int|numeric-string $filterLine Line of the scenario to filter on 33 | */ 34 | public function __construct(int|string $filterLine) 35 | { 36 | $this->filterLine = (int) $filterLine; 37 | } 38 | 39 | /** 40 | * Checks if Feature matches specified filter. 41 | * 42 | * @param FeatureNode $feature Feature instance 43 | * 44 | * @return bool 45 | */ 46 | public function isFeatureMatch(FeatureNode $feature) 47 | { 48 | return $this->filterLine === $feature->getLine(); 49 | } 50 | 51 | /** 52 | * Checks if scenario or outline matches specified filter. 53 | * 54 | * @param ScenarioInterface $scenario Scenario or Outline node instance 55 | * 56 | * @return bool 57 | */ 58 | public function isScenarioMatch(ScenarioInterface $scenario) 59 | { 60 | if ($this->filterLine === $scenario->getLine()) { 61 | return true; 62 | } 63 | 64 | if ($scenario instanceof OutlineNode && $scenario->hasExamples()) { 65 | return $this->filterLine === $scenario->getLine() 66 | || in_array($this->filterLine, $scenario->getExampleTable()->getLines()); 67 | } 68 | 69 | return false; 70 | } 71 | 72 | /** 73 | * Filters feature according to the filter and returns new one. 74 | * 75 | * @return FeatureNode 76 | */ 77 | public function filterFeature(FeatureNode $feature) 78 | { 79 | $scenarios = []; 80 | foreach ($feature->getScenarios() as $scenario) { 81 | if (!$this->isScenarioMatch($scenario)) { 82 | continue; 83 | } 84 | 85 | if ($scenario instanceof OutlineNode && $scenario->hasExamples()) { 86 | foreach ($scenario->getExampleTables() as $exampleTable) { 87 | $table = $exampleTable->getTable(); 88 | $lines = array_keys($table); 89 | 90 | if (in_array($this->filterLine, $lines)) { 91 | $filteredTable = [$lines[0] => $table[$lines[0]]]; 92 | 93 | if ($lines[0] !== $this->filterLine) { 94 | $filteredTable[$this->filterLine] = $table[$this->filterLine]; 95 | } 96 | 97 | $scenario = $scenario->withTables([$exampleTable->withTable($filteredTable)]); 98 | } 99 | } 100 | } 101 | 102 | $scenarios[] = $scenario; 103 | } 104 | 105 | return $feature->withScenarios($scenarios); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Keywords/DialectKeywords.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Keywords; 12 | 13 | use Behat\Gherkin\Dialect\DialectProviderInterface; 14 | use Behat\Gherkin\Dialect\GherkinDialect; 15 | 16 | /** 17 | * An adapter around a DialectProviderInterface to be able to use it with the KeywordsDumper. 18 | * 19 | * TODO add support for dumping an example feature for a dialect directly instead. 20 | * 21 | * @internal 22 | */ 23 | final class DialectKeywords implements KeywordsInterface 24 | { 25 | private GherkinDialect $currentDialect; 26 | 27 | public function __construct( 28 | private readonly DialectProviderInterface $dialectProvider, 29 | ) { 30 | $this->currentDialect = $this->dialectProvider->getDefaultDialect(); 31 | } 32 | 33 | public function setLanguage(string $language): void 34 | { 35 | if ($language === '') { 36 | throw new \InvalidArgumentException('Language cannot be empty'); 37 | } 38 | 39 | $this->currentDialect = $this->dialectProvider->getDialect($language); 40 | } 41 | 42 | public function getFeatureKeywords(): string 43 | { 44 | return $this->getKeywordString($this->currentDialect->getFeatureKeywords()); 45 | } 46 | 47 | public function getBackgroundKeywords(): string 48 | { 49 | return $this->getKeywordString($this->currentDialect->getBackgroundKeywords()); 50 | } 51 | 52 | public function getScenarioKeywords(): string 53 | { 54 | return $this->getKeywordString($this->currentDialect->getScenarioKeywords()); 55 | } 56 | 57 | public function getOutlineKeywords(): string 58 | { 59 | return $this->getKeywordString($this->currentDialect->getScenarioOutlineKeywords()); 60 | } 61 | 62 | public function getExamplesKeywords(): string 63 | { 64 | return $this->getKeywordString($this->currentDialect->getExamplesKeywords()); 65 | } 66 | 67 | public function getGivenKeywords(): string 68 | { 69 | return $this->getStepKeywordString($this->currentDialect->getGivenKeywords()); 70 | } 71 | 72 | public function getWhenKeywords(): string 73 | { 74 | return $this->getStepKeywordString($this->currentDialect->getWhenKeywords()); 75 | } 76 | 77 | public function getThenKeywords(): string 78 | { 79 | return $this->getStepKeywordString($this->currentDialect->getThenKeywords()); 80 | } 81 | 82 | public function getAndKeywords(): string 83 | { 84 | return $this->getStepKeywordString($this->currentDialect->getAndKeywords()); 85 | } 86 | 87 | public function getButKeywords(): string 88 | { 89 | return $this->getStepKeywordString($this->currentDialect->getButKeywords()); 90 | } 91 | 92 | public function getStepKeywords(): string 93 | { 94 | return $this->getStepKeywordString($this->currentDialect->getStepKeywords()); 95 | } 96 | 97 | /** 98 | * @param list $keywords 99 | */ 100 | private function getKeywordString(array $keywords): string 101 | { 102 | return implode('|', $keywords); 103 | } 104 | 105 | /** 106 | * @param list $keywords 107 | */ 108 | private function getStepKeywordString(array $keywords): string 109 | { 110 | $legacyKeywords = []; 111 | foreach ($keywords as $keyword) { 112 | if (str_ends_with($keyword, ' ')) { 113 | $legacyKeywords[] = substr($keyword, 0, -1); 114 | } else { 115 | $legacyKeywords[] = $keyword . '<'; 116 | } 117 | } 118 | 119 | return implode('|', $legacyKeywords); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Filter/LineRangeFilter.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Filter; 12 | 13 | use Behat\Gherkin\Node\FeatureNode; 14 | use Behat\Gherkin\Node\OutlineNode; 15 | use Behat\Gherkin\Node\ScenarioInterface; 16 | 17 | /** 18 | * Filters scenarios by definition line number range. 19 | * 20 | * @author Fabian Kiss 21 | */ 22 | class LineRangeFilter implements FilterInterface 23 | { 24 | /** 25 | * @var int 26 | */ 27 | protected $filterMinLine; 28 | /** 29 | * @var int 30 | */ 31 | protected $filterMaxLine; 32 | 33 | /** 34 | * Initializes filter. 35 | * 36 | * @param int|numeric-string $filterMinLine Minimum line of a scenario to filter on 37 | * @param int|numeric-string|'*' $filterMaxLine Maximum line of a scenario to filter on 38 | */ 39 | public function __construct(int|string $filterMinLine, int|string $filterMaxLine) 40 | { 41 | $this->filterMinLine = (int) $filterMinLine; 42 | $this->filterMaxLine = $filterMaxLine === '*' ? PHP_INT_MAX : (int) $filterMaxLine; 43 | } 44 | 45 | /** 46 | * Checks if Feature matches specified filter. 47 | * 48 | * @param FeatureNode $feature Feature instance 49 | * 50 | * @return bool 51 | */ 52 | public function isFeatureMatch(FeatureNode $feature) 53 | { 54 | return $this->filterMinLine <= $feature->getLine() 55 | && $this->filterMaxLine >= $feature->getLine(); 56 | } 57 | 58 | /** 59 | * Checks if scenario or outline matches specified filter. 60 | * 61 | * @param ScenarioInterface $scenario Scenario or Outline node instance 62 | * 63 | * @return bool 64 | */ 65 | public function isScenarioMatch(ScenarioInterface $scenario) 66 | { 67 | if ($this->filterMinLine <= $scenario->getLine() && $this->filterMaxLine >= $scenario->getLine()) { 68 | return true; 69 | } 70 | 71 | if ($scenario instanceof OutlineNode && $scenario->hasExamples()) { 72 | foreach ($scenario->getExampleTable()->getLines() as $line) { 73 | if ($this->filterMinLine <= $line && $this->filterMaxLine >= $line) { 74 | return true; 75 | } 76 | } 77 | } 78 | 79 | return false; 80 | } 81 | 82 | /** 83 | * Filters feature according to the filter. 84 | * 85 | * @return FeatureNode 86 | */ 87 | public function filterFeature(FeatureNode $feature) 88 | { 89 | $scenarios = []; 90 | foreach ($feature->getScenarios() as $scenario) { 91 | if (!$this->isScenarioMatch($scenario)) { 92 | continue; 93 | } 94 | 95 | if ($scenario instanceof OutlineNode && $scenario->hasExamples()) { 96 | // first accumulate examples and then create scenario 97 | $exampleTableNodes = []; 98 | 99 | foreach ($scenario->getExampleTables() as $exampleTable) { 100 | $table = $exampleTable->getTable(); 101 | $lines = array_keys($table); 102 | 103 | $filteredTable = [$lines[0] => $table[$lines[0]]]; 104 | unset($table[$lines[0]]); 105 | 106 | foreach ($table as $line => $row) { 107 | if ($this->filterMinLine <= $line && $this->filterMaxLine >= $line) { 108 | $filteredTable[$line] = $row; 109 | } 110 | } 111 | 112 | if (count($filteredTable) > 1) { 113 | $exampleTableNodes[] = $exampleTable->withTable($filteredTable); 114 | } 115 | } 116 | 117 | $scenario = $scenario->withTables($exampleTableNodes); 118 | } 119 | 120 | $scenarios[] = $scenario; 121 | } 122 | 123 | return $feature->withScenarios($scenarios); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Dialect/KeywordsDialectProvider.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Dialect; 12 | 13 | use Behat\Gherkin\Exception\NoSuchLanguageException; 14 | use Behat\Gherkin\Keywords\ArrayKeywords; 15 | use Behat\Gherkin\Keywords\KeywordsInterface; 16 | 17 | /** 18 | * Adapter for the legacy keywords interface. 19 | * 20 | * @internal 21 | */ 22 | final class KeywordsDialectProvider implements DialectProviderInterface 23 | { 24 | private readonly string $defaultLanguage; 25 | 26 | public function __construct( 27 | private readonly KeywordsInterface $keywords, 28 | ) { 29 | // Assume a default dialect of `en` as the KeywordsInterface does not allow reading its language but returns the current data 30 | $this->defaultLanguage = $this->keywords instanceof ArrayKeywords ? $this->keywords->getLanguage() : 'en'; 31 | } 32 | 33 | public function getDialect(string $language): GherkinDialect 34 | { 35 | // The legacy keywords interface doesn't support detecting whether changing the language worked or no. 36 | $this->keywords->setLanguage($language); 37 | 38 | if ($this->keywords instanceof ArrayKeywords && $this->keywords->getLanguage() !== $language) { 39 | throw new NoSuchLanguageException($language); 40 | } 41 | 42 | return $this->buildDialect($language); 43 | } 44 | 45 | public function getDefaultDialect(): GherkinDialect 46 | { 47 | $this->keywords->setLanguage($this->defaultLanguage); 48 | 49 | return $this->buildDialect($this->defaultLanguage); 50 | } 51 | 52 | private function buildDialect(string $language): GherkinDialect 53 | { 54 | return new GherkinDialect($language, [ 55 | 'feature' => self::parseKeywords($this->keywords->getFeatureKeywords()), 56 | 'background' => self::parseKeywords($this->keywords->getBackgroundKeywords()), 57 | 'scenario' => self::parseKeywords($this->keywords->getScenarioKeywords()), 58 | 'scenarioOutline' => self::parseKeywords($this->keywords->getOutlineKeywords()), 59 | 'examples' => self::parseKeywords($this->keywords->getExamplesKeywords()), 60 | 'rule' => ['Rule'], // Hardcoded value as our old keywords interface doesn't support rules. 61 | 'given' => self::parseStepKeywords($this->keywords->getGivenKeywords()), 62 | 'when' => self::parseStepKeywords($this->keywords->getWhenKeywords()), 63 | 'then' => self::parseStepKeywords($this->keywords->getThenKeywords()), 64 | 'and' => self::parseStepKeywords($this->keywords->getAndKeywords()), 65 | 'but' => self::parseStepKeywords($this->keywords->getButKeywords()), 66 | ]); 67 | } 68 | 69 | /** 70 | * @return non-empty-list 71 | */ 72 | private static function parseKeywords(string $keywordString): array 73 | { 74 | $keywords = array_values(array_filter(explode('|', $keywordString))); 75 | 76 | if ($keywords === []) { 77 | throw new \LogicException('A keyword string must contain at least one keyword.'); 78 | } 79 | 80 | return $keywords; 81 | } 82 | 83 | /** 84 | * @return non-empty-list 85 | */ 86 | private static function parseStepKeywords(string $keywordString): array 87 | { 88 | $legacyKeywords = explode('|', $keywordString); 89 | $keywords = []; 90 | 91 | foreach ($legacyKeywords as $legacyKeyword) { 92 | if (\strlen($legacyKeyword) >= 2 && str_ends_with($legacyKeyword, '<')) { 93 | $keyword = substr($legacyKeyword, 0, -1); 94 | \assert($keyword !== ''); // phpstan is not smart enough to detect that the length check above guarantees this invariant 95 | $keywords[] = $keyword; 96 | } else { 97 | $keywords[] = $legacyKeyword . ' '; 98 | } 99 | } 100 | 101 | return $keywords; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Cache/FileCache.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Cache; 12 | 13 | use Behat\Gherkin\Exception\CacheException; 14 | use Behat\Gherkin\Exception\FilesystemException; 15 | use Behat\Gherkin\Filesystem; 16 | use Behat\Gherkin\Node\FeatureNode; 17 | use Composer\InstalledVersions; 18 | 19 | /** 20 | * File cache. 21 | * Caches feature into a file. 22 | * 23 | * @author Konstantin Kudryashov 24 | */ 25 | class FileCache implements CacheInterface 26 | { 27 | private readonly string $path; 28 | 29 | /** 30 | * Used as part of the cache directory path to invalidate cache if the installed package version changes. 31 | */ 32 | private static function getGherkinVersionHash(): string 33 | { 34 | $version = InstalledVersions::getVersion('behat/gherkin') ?? 'unknown'; 35 | 36 | // Composer version strings can contain arbitrary content so hash for filesystem safety 37 | return md5($version); 38 | } 39 | 40 | /** 41 | * Initializes file cache. 42 | * 43 | * @param string $path path to the folder where to store caches 44 | * 45 | * @throws CacheException 46 | */ 47 | public function __construct(string $path) 48 | { 49 | $this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . self::getGherkinVersionHash(); 50 | 51 | try { 52 | Filesystem::ensureDirectoryExists($this->path); 53 | } catch (FilesystemException $ex) { 54 | throw new CacheException( 55 | sprintf( 56 | 'Cache path "%s" cannot be created or is not a directory: %s', 57 | $this->path, 58 | $ex->getMessage(), 59 | ), 60 | previous: $ex 61 | ); 62 | } 63 | 64 | if (!is_writable($this->path)) { 65 | throw new CacheException(sprintf('Cache path "%s" is not writeable. Check your filesystem permissions or disable Gherkin file cache.', $this->path)); 66 | } 67 | } 68 | 69 | /** 70 | * Checks that cache for feature exists and is fresh. 71 | * 72 | * @param string $path Feature path 73 | * @param int $timestamp The last time feature was updated 74 | * 75 | * @return bool 76 | */ 77 | public function isFresh(string $path, int $timestamp) 78 | { 79 | $cachePath = $this->getCachePathFor($path); 80 | 81 | if (!file_exists($cachePath)) { 82 | return false; 83 | } 84 | 85 | return Filesystem::getLastModified($cachePath) > $timestamp; 86 | } 87 | 88 | /** 89 | * Reads feature cache from path. 90 | * 91 | * @param string $path Feature path 92 | * 93 | * @return FeatureNode 94 | * 95 | * @throws CacheException 96 | */ 97 | public function read(string $path) 98 | { 99 | $cachePath = $this->getCachePathFor($path); 100 | try { 101 | $feature = unserialize(Filesystem::readFile($cachePath), ['allowed_classes' => true]); 102 | } catch (FilesystemException $ex) { 103 | throw new CacheException("Can not load cache: {$ex->getMessage()}", previous: $ex); 104 | } 105 | 106 | if (!$feature instanceof FeatureNode) { 107 | throw new CacheException(sprintf('Can not load cache for a feature "%s" from "%s".', $path, $cachePath)); 108 | } 109 | 110 | return $feature; 111 | } 112 | 113 | /** 114 | * Caches feature node. 115 | * 116 | * @param string $path Feature path 117 | * 118 | * @return void 119 | */ 120 | public function write(string $path, FeatureNode $feature) 121 | { 122 | file_put_contents($this->getCachePathFor($path), serialize($feature)); 123 | } 124 | 125 | /** 126 | * Returns feature cache file path from features path. 127 | * 128 | * @param string $path Feature path 129 | * 130 | * @return string 131 | */ 132 | protected function getCachePathFor(string $path) 133 | { 134 | return $this->path . '/' . md5($path) . '.feature.cache'; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Dialect/GherkinDialect.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Dialect; 12 | 13 | /** 14 | * @phpstan-type TDialectData array{ 15 | * feature: non-empty-list, 16 | * background: non-empty-list, 17 | * scenario: non-empty-list, 18 | * scenarioOutline: non-empty-list, 19 | * examples: non-empty-list, 20 | * rule: non-empty-list, 21 | * given: non-empty-list, 22 | * when: non-empty-list, 23 | * then: non-empty-list, 24 | * and: non-empty-list, 25 | * but: non-empty-list, 26 | * } 27 | */ 28 | final class GherkinDialect 29 | { 30 | /** 31 | * @var non-empty-list|null 32 | */ 33 | private ?array $stepKeywordsCache = null; 34 | 35 | /** 36 | * @phpstan-param TDialectData $dialect 37 | */ 38 | public function __construct( 39 | private readonly string $language, 40 | private readonly array $dialect, 41 | ) { 42 | } 43 | 44 | public function getLanguage(): string 45 | { 46 | return $this->language; 47 | } 48 | 49 | /** 50 | * @return non-empty-list 51 | */ 52 | public function getFeatureKeywords(): array 53 | { 54 | return $this->dialect['feature']; 55 | } 56 | 57 | /** 58 | * @return non-empty-list 59 | */ 60 | public function getBackgroundKeywords(): array 61 | { 62 | return $this->dialect['background']; 63 | } 64 | 65 | /** 66 | * @return non-empty-list 67 | */ 68 | public function getScenarioKeywords(): array 69 | { 70 | return $this->dialect['scenario']; 71 | } 72 | 73 | /** 74 | * @return non-empty-list 75 | */ 76 | public function getScenarioOutlineKeywords(): array 77 | { 78 | return $this->dialect['scenarioOutline']; 79 | } 80 | 81 | /** 82 | * @return non-empty-list 83 | */ 84 | public function getRuleKeywords(): array 85 | { 86 | return $this->dialect['rule']; 87 | } 88 | 89 | /** 90 | * @return non-empty-list 91 | */ 92 | public function getGivenKeywords(): array 93 | { 94 | return $this->dialect['given']; 95 | } 96 | 97 | /** 98 | * @return non-empty-list 99 | */ 100 | public function getWhenKeywords(): array 101 | { 102 | return $this->dialect['when']; 103 | } 104 | 105 | /** 106 | * @return non-empty-list 107 | */ 108 | public function getThenKeywords(): array 109 | { 110 | return $this->dialect['then']; 111 | } 112 | 113 | /** 114 | * @return non-empty-list 115 | */ 116 | public function getAndKeywords(): array 117 | { 118 | return $this->dialect['and']; 119 | } 120 | 121 | /** 122 | * @return non-empty-list 123 | */ 124 | public function getButKeywords(): array 125 | { 126 | return $this->dialect['but']; 127 | } 128 | 129 | /** 130 | * @return non-empty-list 131 | */ 132 | public function getStepKeywords(): array 133 | { 134 | if ($this->stepKeywordsCache !== null) { 135 | return $this->stepKeywordsCache; 136 | } 137 | 138 | $stepKeywords = [ 139 | ...$this->getGivenKeywords(), 140 | ...$this->getWhenKeywords(), 141 | ...$this->getThenKeywords(), 142 | ...$this->getAndKeywords(), 143 | ...$this->getButKeywords(), 144 | ]; 145 | 146 | // Sort longer keywords before shorter keywords being their prefix 147 | rsort($stepKeywords); 148 | 149 | return $this->stepKeywordsCache = $stepKeywords; 150 | } 151 | 152 | /** 153 | * @return non-empty-list 154 | */ 155 | public function getExamplesKeywords(): array 156 | { 157 | return $this->dialect['examples']; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/Gherkin.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin; 12 | 13 | use Behat\Gherkin\Filter\FeatureFilterInterface; 14 | use Behat\Gherkin\Filter\LineFilter; 15 | use Behat\Gherkin\Filter\LineRangeFilter; 16 | use Behat\Gherkin\Loader\FileLoaderInterface; 17 | use Behat\Gherkin\Loader\LoaderInterface; 18 | use Behat\Gherkin\Node\FeatureNode; 19 | 20 | /** 21 | * Gherkin manager. 22 | * 23 | * @author Konstantin Kudryashov 24 | */ 25 | class Gherkin 26 | { 27 | /** 28 | * @deprecated this constant will not be updated for releases after 4.8.0 and will be removed in the next major. 29 | * You can use composer's runtime API to get the behat version if you need it. Note that composer's versions will 30 | * not always be simple numeric values. 31 | */ 32 | public const VERSION = '4.8.0'; 33 | 34 | /** 35 | * @var list> 36 | */ 37 | protected $loaders = []; 38 | /** 39 | * @var list 40 | */ 41 | protected $filters = []; 42 | 43 | /** 44 | * Adds loader to manager. 45 | * 46 | * @param LoaderInterface<*> $loader Feature loader 47 | * 48 | * @return void 49 | */ 50 | public function addLoader(LoaderInterface $loader) 51 | { 52 | $this->loaders[] = $loader; 53 | } 54 | 55 | /** 56 | * Adds filter to manager. 57 | * 58 | * @param FeatureFilterInterface $filter Feature filter 59 | * 60 | * @return void 61 | */ 62 | public function addFilter(FeatureFilterInterface $filter) 63 | { 64 | $this->filters[] = $filter; 65 | } 66 | 67 | /** 68 | * Sets filters to the parser. 69 | * 70 | * @param array $filters 71 | * 72 | * @return void 73 | */ 74 | public function setFilters(array $filters) 75 | { 76 | $this->filters = []; 77 | array_map($this->addFilter(...), $filters); 78 | } 79 | 80 | /** 81 | * Sets base features path. 82 | * 83 | * @param string $path Loaders base path 84 | * 85 | * @return void 86 | */ 87 | public function setBasePath(string $path) 88 | { 89 | foreach ($this->loaders as $loader) { 90 | if ($loader instanceof FileLoaderInterface) { 91 | $loader->setBasePath($path); 92 | } 93 | } 94 | } 95 | 96 | /** 97 | * Loads & filters resource with added loaders. 98 | * 99 | * @param mixed $resource Resource to load 100 | * @param array $filters Additional filters 101 | * 102 | * @return list 103 | */ 104 | public function load($resource, array $filters = []) 105 | { 106 | $filters = array_merge($this->filters, $filters); 107 | 108 | $matches = []; 109 | if (is_scalar($resource) || $resource instanceof \Stringable) { 110 | if (preg_match('/^(.*):(\d+)-(\d+|\*)$/', (string) $resource, $matches)) { 111 | $resource = $matches[1]; 112 | $filters[] = new LineRangeFilter($matches[2], $matches[3]); 113 | } elseif (preg_match('/^(.*):(\d+)$/', (string) $resource, $matches)) { 114 | $resource = $matches[1]; 115 | $filters[] = new LineFilter($matches[2]); 116 | } 117 | } 118 | 119 | $loader = $this->resolveLoader($resource); 120 | 121 | if ($loader === null) { 122 | return []; 123 | } 124 | 125 | $features = []; 126 | foreach ($loader->load($resource) as $feature) { 127 | foreach ($filters as $filter) { 128 | $feature = $filter->filterFeature($feature); 129 | 130 | if (!$feature->hasScenarios() && !$filter->isFeatureMatch($feature)) { 131 | continue 2; 132 | } 133 | } 134 | 135 | $features[] = $feature; 136 | } 137 | 138 | return $features; 139 | } 140 | 141 | /** 142 | * Resolves loader by resource. 143 | * 144 | * @template TResourceType 145 | * 146 | * @param TResourceType $resource Resource to load 147 | * 148 | * @return LoaderInterface|null 149 | */ 150 | public function resolveLoader(mixed $resource) 151 | { 152 | foreach ($this->loaders as $loader) { 153 | if ($loader->supports($resource)) { 154 | return $loader; 155 | } 156 | } 157 | 158 | return null; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/Node/FeatureNode.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | use InvalidArgumentException; 14 | 15 | use function strlen; 16 | 17 | /** 18 | * Represents Gherkin Feature. 19 | * 20 | * @author Konstantin Kudryashov 21 | * 22 | * @final since 4.15.0 23 | */ 24 | class FeatureNode implements KeywordNodeInterface, TaggedNodeInterface, DescribableNodeInterface 25 | { 26 | use TaggedNodeTrait; 27 | 28 | /** 29 | * @param list $tags 30 | * @param ScenarioInterface[] $scenarios 31 | * @param string|null $file the absolute path to the feature file 32 | */ 33 | public function __construct( 34 | private readonly ?string $title, 35 | private readonly ?string $description, 36 | private readonly array $tags, 37 | private readonly ?BackgroundNode $background, 38 | private readonly array $scenarios, 39 | private readonly string $keyword, 40 | private readonly string $language, 41 | private readonly ?string $file, 42 | private readonly int $line, 43 | ) { 44 | // Verify that the feature file is an absolute path. 45 | if (!empty($file) && !$this->isAbsolutePath($file)) { 46 | throw new InvalidArgumentException('The file should be an absolute path.'); 47 | } 48 | } 49 | 50 | /** 51 | * Returns node type string. 52 | * 53 | * @return string 54 | */ 55 | public function getNodeType() 56 | { 57 | return 'Feature'; 58 | } 59 | 60 | /** 61 | * Returns feature title. 62 | * 63 | * @return string|null 64 | */ 65 | public function getTitle() 66 | { 67 | return $this->title; 68 | } 69 | 70 | /** 71 | * Checks if feature has a description. 72 | * 73 | * @return bool 74 | */ 75 | public function hasDescription() 76 | { 77 | return !empty($this->description); 78 | } 79 | 80 | /** 81 | * Returns feature description. 82 | * 83 | * @return string|null 84 | */ 85 | public function getDescription() 86 | { 87 | return $this->description; 88 | } 89 | 90 | public function getTags() 91 | { 92 | return $this->tags; 93 | } 94 | 95 | /** 96 | * Checks if feature has background. 97 | * 98 | * @return bool 99 | */ 100 | public function hasBackground() 101 | { 102 | return $this->background !== null; 103 | } 104 | 105 | /** 106 | * Returns feature background. 107 | * 108 | * @return BackgroundNode|null 109 | */ 110 | public function getBackground() 111 | { 112 | return $this->background; 113 | } 114 | 115 | /** 116 | * Checks if feature has scenarios. 117 | * 118 | * @return bool 119 | */ 120 | public function hasScenarios() 121 | { 122 | return count($this->scenarios) > 0; 123 | } 124 | 125 | /** 126 | * Returns feature scenarios. 127 | * 128 | * @return ScenarioInterface[] 129 | */ 130 | public function getScenarios() 131 | { 132 | return $this->scenarios; 133 | } 134 | 135 | /** 136 | * Returns feature keyword. 137 | * 138 | * @return string 139 | */ 140 | public function getKeyword() 141 | { 142 | return $this->keyword; 143 | } 144 | 145 | /** 146 | * Returns feature language. 147 | * 148 | * @return string 149 | */ 150 | public function getLanguage() 151 | { 152 | return $this->language; 153 | } 154 | 155 | /** 156 | * Returns feature file as an absolute path. 157 | * 158 | * @return string|null 159 | */ 160 | public function getFile() 161 | { 162 | return $this->file; 163 | } 164 | 165 | /** 166 | * Returns feature declaration line number. 167 | * 168 | * @return int 169 | */ 170 | public function getLine() 171 | { 172 | return $this->line; 173 | } 174 | 175 | /** 176 | * Returns a copy of this feature, but with a different set of scenarios. 177 | * 178 | * @param array $scenarios 179 | */ 180 | public function withScenarios(array $scenarios): self 181 | { 182 | return new self( 183 | $this->title, 184 | $this->description, 185 | $this->tags, 186 | $this->background, 187 | array_values($scenarios), 188 | $this->keyword, 189 | $this->language, 190 | $this->file, 191 | $this->line, 192 | ); 193 | } 194 | 195 | /** 196 | * Returns whether the file path is an absolute path. 197 | * 198 | * @param string|null $file A file path 199 | * 200 | * @return bool 201 | * 202 | * @see https://github.com/symfony/filesystem/blob/master/Filesystem.php 203 | */ 204 | protected function isAbsolutePath(?string $file) 205 | { 206 | if ($file === null) { 207 | throw new InvalidArgumentException('The provided file path must not be null.'); 208 | } 209 | 210 | return strspn($file, '/\\', 0, 1) 211 | || (strlen($file) > 3 && ctype_alpha($file[0]) 212 | && $file[1] === ':' 213 | && strspn($file, '/\\', 2, 1) 214 | ) 215 | || parse_url($file, PHP_URL_SCHEME) !== null; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/Node/OutlineNode.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | /** 14 | * Represents Gherkin Outline. 15 | * 16 | * @author Konstantin Kudryashov 17 | * 18 | * @final since 4.15.0 19 | */ 20 | class OutlineNode implements ScenarioInterface, DescribableNodeInterface 21 | { 22 | use TaggedNodeTrait; 23 | 24 | /** 25 | * @var array 26 | */ 27 | private array $tables; 28 | /** 29 | * @var ExampleNode[] 30 | */ 31 | private array $examples; 32 | 33 | /** 34 | * @param list $tags 35 | * @param StepNode[] $steps 36 | * @param ExampleTableNode|array $tables 37 | */ 38 | public function __construct( 39 | private readonly ?string $title, 40 | private readonly array $tags, 41 | private readonly array $steps, 42 | array|ExampleTableNode $tables, 43 | private readonly string $keyword, 44 | private readonly int $line, 45 | private readonly ?string $description = null, 46 | ) { 47 | $this->tables = is_array($tables) ? $tables : [$tables]; 48 | } 49 | 50 | /** 51 | * Returns node type string. 52 | * 53 | * @return string 54 | */ 55 | public function getNodeType() 56 | { 57 | return 'Outline'; 58 | } 59 | 60 | /** 61 | * Returns outline title. 62 | * 63 | * @return string|null 64 | */ 65 | public function getTitle() 66 | { 67 | return $this->title; 68 | } 69 | 70 | public function getDescription(): ?string 71 | { 72 | return $this->description; 73 | } 74 | 75 | public function getTags() 76 | { 77 | return $this->tags; 78 | } 79 | 80 | /** 81 | * Checks if outline has steps. 82 | * 83 | * @return bool 84 | */ 85 | public function hasSteps() 86 | { 87 | return count($this->steps) > 0; 88 | } 89 | 90 | /** 91 | * Returns outline steps. 92 | * 93 | * @return StepNode[] 94 | */ 95 | public function getSteps() 96 | { 97 | return $this->steps; 98 | } 99 | 100 | /** 101 | * Checks if outline has examples. 102 | * 103 | * @return bool 104 | */ 105 | public function hasExamples() 106 | { 107 | return count($this->tables) > 0; 108 | } 109 | 110 | /** 111 | * Builds and returns examples table for the outline. 112 | * 113 | * WARNING: it returns a merged table with tags, names & descriptions lost. 114 | * 115 | * @return ExampleTableNode 116 | * 117 | * @deprecated use getExampleTables instead 118 | */ 119 | public function getExampleTable() 120 | { 121 | $table = []; 122 | foreach ($this->tables[0]->getTable() as $k => $v) { 123 | $table[$k] = $v; 124 | } 125 | 126 | /** @var ExampleTableNode $exampleTableNode */ 127 | $exampleTableNode = new ExampleTableNode($table, $this->tables[0]->getKeyword()); 128 | $tableCount = count($this->tables); 129 | for ($i = 1; $i < $tableCount; ++$i) { 130 | $exampleTableNode->mergeRowsFromTable($this->tables[$i]); 131 | } 132 | 133 | return $exampleTableNode; 134 | } 135 | 136 | /** 137 | * Returns list of examples for the outline. 138 | * 139 | * @return ExampleNode[] 140 | */ 141 | public function getExamples() 142 | { 143 | return $this->examples ??= $this->createExamples(); 144 | } 145 | 146 | /** 147 | * Returns examples tables array for the outline. 148 | * 149 | * @return array 150 | */ 151 | public function getExampleTables() 152 | { 153 | return $this->tables; 154 | } 155 | 156 | /** 157 | * Returns outline keyword. 158 | * 159 | * @return string 160 | */ 161 | public function getKeyword() 162 | { 163 | return $this->keyword; 164 | } 165 | 166 | /** 167 | * Returns outline declaration line number. 168 | * 169 | * @return int 170 | */ 171 | public function getLine() 172 | { 173 | return $this->line; 174 | } 175 | 176 | /** 177 | * Returns a copy of this outline, but with a different table. 178 | * 179 | * @param array $exampleTables 180 | */ 181 | public function withTables(array $exampleTables): self 182 | { 183 | return new self( 184 | $this->title, 185 | $this->tags, 186 | $this->steps, 187 | $exampleTables, 188 | $this->keyword, 189 | $this->line, 190 | $this->description, 191 | ); 192 | } 193 | 194 | /** 195 | * Creates examples for this outline using examples table. 196 | * 197 | * @return ExampleNode[] 198 | */ 199 | protected function createExamples() 200 | { 201 | $examples = []; 202 | 203 | foreach ($this->getExampleTables() as $exampleTable) { 204 | foreach ($exampleTable->getColumnsHash() as $rowNum => $row) { 205 | $examples[] = new ExampleNode( 206 | $exampleTable->getRowAsString($rowNum + 1), 207 | array_merge($this->tags, $exampleTable->getTags()), 208 | $this->getSteps(), 209 | $row, 210 | $exampleTable->getRowLine($rowNum + 1), 211 | $this->getTitle(), 212 | $rowNum + 1 213 | ); 214 | } 215 | } 216 | 217 | return $examples; 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/Filesystem.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin; 12 | 13 | use Behat\Gherkin\Exception\FilesystemException; 14 | use ErrorException; 15 | use JsonException; 16 | use RecursiveDirectoryIterator; 17 | use RecursiveIteratorIterator; 18 | use SplFileInfo; 19 | 20 | use function assert; 21 | 22 | /** 23 | * @internal 24 | */ 25 | final class Filesystem 26 | { 27 | /** 28 | * @throws FilesystemException 29 | */ 30 | public static function readFile(string $fileName): string 31 | { 32 | try { 33 | $result = self::callSafely(static fn () => file_get_contents($fileName)); 34 | } catch (ErrorException $e) { 35 | throw new FilesystemException( 36 | sprintf('File "%s" cannot be read: %s', $fileName, $e->getMessage()), 37 | previous: $e, 38 | ); 39 | } 40 | 41 | assert($result !== false, 'file_get_contents() should not return false without emitting a PHP warning'); 42 | 43 | return $result; 44 | } 45 | 46 | public static function writeFile(string $fileName, string $content): void 47 | { 48 | self::ensureDirectoryExists(dirname($fileName)); 49 | try { 50 | $result = self::callSafely(static fn () => file_put_contents($fileName, $content)); 51 | } catch (ErrorException $e) { 52 | throw new FilesystemException( 53 | sprintf('File "%s" cannot be written: %s', $fileName, $e->getMessage()), 54 | previous: $e, 55 | ); 56 | } 57 | 58 | assert($result !== false, 'file_put_contents() should not return false without emitting a PHP warning'); 59 | } 60 | 61 | /** 62 | * @return array 63 | * 64 | * @throws JsonException|FilesystemException 65 | */ 66 | public static function readJsonFileArray(string $fileName): array 67 | { 68 | $result = json_decode(self::readFile($fileName), true, flags: JSON_THROW_ON_ERROR); 69 | 70 | assert(is_array($result), 'File must contain JSON with an array or object at its root'); 71 | 72 | return $result; 73 | } 74 | 75 | /** 76 | * @return array 77 | * 78 | * @throws JsonException|FilesystemException 79 | */ 80 | public static function readJsonFileHash(string $fileName): array 81 | { 82 | $result = self::readJsonFileArray($fileName); 83 | assert( 84 | $result === array_filter($result, is_string(...), ARRAY_FILTER_USE_KEY), 85 | 'File must contain a JSON object at its root', 86 | ); 87 | 88 | return $result; 89 | } 90 | 91 | /** 92 | * @return list 93 | */ 94 | public static function findFilesRecursively(string $path, string $pattern): array 95 | { 96 | /** 97 | * @var iterable $fileIterator 98 | */ 99 | $fileIterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path), RecursiveIteratorIterator::CHILD_FIRST); 100 | 101 | $found = []; 102 | foreach ($fileIterator as $file) { 103 | if ($file->isFile() && fnmatch($pattern, $file->getFilename())) { 104 | $found[] = $file->getPathname(); 105 | } 106 | } 107 | 108 | return $found; 109 | } 110 | 111 | public static function getLastModified(string $fileName): int 112 | { 113 | try { 114 | $result = self::callSafely(static fn () => filemtime($fileName)); 115 | } catch (ErrorException $e) { 116 | throw new FilesystemException( 117 | sprintf('Last modification time of file "%s" cannot be found: %s', $fileName, $e->getMessage()), 118 | previous: $e, 119 | ); 120 | } 121 | 122 | assert($result !== false, 'filemtime() should not return false without emitting a PHP warning'); 123 | 124 | return $result; 125 | } 126 | 127 | public static function getRealPath(string $path): string 128 | { 129 | $result = realpath($path); 130 | 131 | if ($result === false) { 132 | throw new FilesystemException("Cannot retrieve the real path of $path"); 133 | } 134 | 135 | return $result; 136 | } 137 | 138 | public static function ensureDirectoryExists(string $path): void 139 | { 140 | if (is_dir($path)) { 141 | return; 142 | } 143 | 144 | try { 145 | $result = self::callSafely(static fn () => mkdir($path, 0777, true)); 146 | 147 | assert($result !== false, 'mkdir() should not return false without emitting a PHP warning'); 148 | } catch (ErrorException $e) { 149 | // @codeCoverageIgnoreStart 150 | if (is_dir($path)) { 151 | // Some other concurrent process created the directory. 152 | return; 153 | } 154 | // @codeCoverageIgnoreEnd 155 | 156 | throw new FilesystemException( 157 | sprintf('Path at "%s" cannot be created: %s', $path, $e->getMessage()), 158 | previous: $e, 159 | ); 160 | } 161 | } 162 | 163 | /** 164 | * @template TResult 165 | * 166 | * @param (callable(): TResult) $callback 167 | * 168 | * @return TResult 169 | * 170 | * @throws ErrorException 171 | */ 172 | private static function callSafely(callable $callback): mixed 173 | { 174 | set_error_handler( 175 | static fn (int $severity, string $message, string $file, int $line) => throw new ErrorException($message, 0, $severity, $file, $line) 176 | ); 177 | 178 | try { 179 | return $callback(); 180 | } finally { 181 | restore_error_handler(); 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/Keywords/ArrayKeywords.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Keywords; 12 | 13 | /** 14 | * Array initializable keywords holder. 15 | * 16 | * ``` 17 | * $keywords = new Behat\Gherkin\Keywords\ArrayKeywords(array( 18 | * 'en' => array( 19 | * 'feature' => 'Feature', 20 | * 'background' => 'Background', 21 | * 'scenario' => 'Scenario', 22 | * 'scenario_outline' => 'Scenario Outline|Scenario Template', 23 | * 'examples' => 'Examples|Scenarios', 24 | * 'given' => 'Given', 25 | * 'when' => 'When', 26 | * 'then' => 'Then', 27 | * 'and' => 'And', 28 | * 'but' => 'But' 29 | * ), 30 | * 'ru' => array( 31 | * 'feature' => 'Функционал', 32 | * 'background' => 'Предыстория', 33 | * 'scenario' => 'Сценарий', 34 | * 'scenario_outline' => 'Структура сценария', 35 | * 'examples' => 'Примеры', 36 | * 'given' => 'Допустим', 37 | * 'when' => 'Если', 38 | * 'then' => 'То', 39 | * 'and' => 'И', 40 | * 'but' => 'Но' 41 | * ) 42 | * )); 43 | * ``` 44 | * 45 | * @author Konstantin Kudryashov 46 | * 47 | * @phpstan-type TKeywordsArray array{ 48 | * feature: string, 49 | * background: string, 50 | * scenario: string, 51 | * scenario_outline: string, 52 | * examples: string, 53 | * given: string, 54 | * when: string, 55 | * then: string, 56 | * and: string, 57 | * but: string, 58 | * } 59 | * @phpstan-type TMultiLanguageKeywords array 60 | */ 61 | class ArrayKeywords implements KeywordsInterface 62 | { 63 | /** 64 | * @var array 65 | */ 66 | private array $keywordString = []; 67 | private string $language = 'en'; 68 | 69 | /** 70 | * Initializes holder with keywords. 71 | * 72 | * @phpstan-param TMultiLanguageKeywords $keywords Keywords array 73 | */ 74 | public function __construct( 75 | private readonly array $keywords, 76 | ) { 77 | } 78 | 79 | /** 80 | * Sets keywords holder language. 81 | * 82 | * @return void 83 | */ 84 | public function setLanguage(string $language) 85 | { 86 | if (!isset($this->keywords[$language])) { 87 | $this->language = 'en'; 88 | } else { 89 | $this->language = $language; 90 | } 91 | } 92 | 93 | /** 94 | * @internal 95 | */ 96 | public function getLanguage(): string 97 | { 98 | return $this->language; 99 | } 100 | 101 | /** 102 | * Returns Feature keywords (separated by "|"). 103 | * 104 | * @return string 105 | */ 106 | public function getFeatureKeywords() 107 | { 108 | return $this->keywords[$this->language]['feature']; 109 | } 110 | 111 | /** 112 | * Returns Background keywords (separated by "|"). 113 | * 114 | * @return string 115 | */ 116 | public function getBackgroundKeywords() 117 | { 118 | return $this->keywords[$this->language]['background']; 119 | } 120 | 121 | /** 122 | * Returns Scenario keywords (separated by "|"). 123 | * 124 | * @return string 125 | */ 126 | public function getScenarioKeywords() 127 | { 128 | return $this->keywords[$this->language]['scenario']; 129 | } 130 | 131 | /** 132 | * Returns Scenario Outline keywords (separated by "|"). 133 | * 134 | * @return string 135 | */ 136 | public function getOutlineKeywords() 137 | { 138 | return $this->keywords[$this->language]['scenario_outline']; 139 | } 140 | 141 | /** 142 | * Returns Examples keywords (separated by "|"). 143 | * 144 | * @return string 145 | */ 146 | public function getExamplesKeywords() 147 | { 148 | return $this->keywords[$this->language]['examples']; 149 | } 150 | 151 | /** 152 | * Returns Given keywords (separated by "|"). 153 | * 154 | * @return string 155 | */ 156 | public function getGivenKeywords() 157 | { 158 | return $this->keywords[$this->language]['given']; 159 | } 160 | 161 | /** 162 | * Returns When keywords (separated by "|"). 163 | * 164 | * @return string 165 | */ 166 | public function getWhenKeywords() 167 | { 168 | return $this->keywords[$this->language]['when']; 169 | } 170 | 171 | /** 172 | * Returns Then keywords (separated by "|"). 173 | * 174 | * @return string 175 | */ 176 | public function getThenKeywords() 177 | { 178 | return $this->keywords[$this->language]['then']; 179 | } 180 | 181 | /** 182 | * Returns And keywords (separated by "|"). 183 | * 184 | * @return string 185 | */ 186 | public function getAndKeywords() 187 | { 188 | return $this->keywords[$this->language]['and']; 189 | } 190 | 191 | /** 192 | * Returns But keywords (separated by "|"). 193 | * 194 | * @return string 195 | */ 196 | public function getButKeywords() 197 | { 198 | return $this->keywords[$this->language]['but']; 199 | } 200 | 201 | /** 202 | * Returns all step keywords (Given, When, Then, And, But). 203 | * 204 | * @return string 205 | */ 206 | public function getStepKeywords() 207 | { 208 | if (!isset($this->keywordString[$this->language])) { 209 | $keywords = array_merge( 210 | explode('|', $this->getGivenKeywords()), 211 | explode('|', $this->getWhenKeywords()), 212 | explode('|', $this->getThenKeywords()), 213 | explode('|', $this->getAndKeywords()), 214 | explode('|', $this->getButKeywords()) 215 | ); 216 | 217 | usort($keywords, function ($keyword1, $keyword2) { 218 | return mb_strlen($keyword2, 'utf8') - mb_strlen($keyword1, 'utf8'); 219 | }); 220 | 221 | $this->keywordString[$this->language] = implode('|', $keywords); 222 | } 223 | 224 | return $this->keywordString[$this->language]; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/Filter/TagFilter.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Filter; 12 | 13 | use Behat\Gherkin\Node\FeatureNode; 14 | use Behat\Gherkin\Node\OutlineNode; 15 | use Behat\Gherkin\Node\ScenarioInterface; 16 | 17 | /** 18 | * Filters scenarios by feature/scenario tag. 19 | * 20 | * @author Konstantin Kudryashov 21 | */ 22 | class TagFilter extends ComplexFilter 23 | { 24 | /** 25 | * @var string 26 | */ 27 | protected $filterString; 28 | 29 | public function __construct(string $filterString) 30 | { 31 | $filterString = trim($filterString); 32 | $fixedFilterString = $this->fixLegacyFilterStringWithoutPrefixes($filterString); 33 | // @todo trigger a deprecation here $filterString !== $fixedFilterString 34 | $this->filterString = $fixedFilterString; 35 | 36 | if (preg_match('/\s/u', $this->filterString)) { 37 | trigger_error( 38 | 'Tags with whitespace are deprecated and may be removed in a future version', 39 | E_USER_DEPRECATED 40 | ); 41 | } 42 | } 43 | 44 | /** 45 | * Fix tag expressions where the filter string does not include the `@` prefixes. 46 | * 47 | * e.g. `new TagFilter('wip&&~slow')` rather than `new TagFilter('@wip&&~@slow')`. These were historically 48 | * supported, although not officially, and have been reinstated to solve a BC issue. This syntax will be deprecated 49 | * and removed in future. 50 | */ 51 | private function fixLegacyFilterStringWithoutPrefixes(string $filterString): string 52 | { 53 | if ($filterString === '') { 54 | return ''; 55 | } 56 | 57 | $allParts = []; 58 | foreach (explode('&&', $filterString) as $andTags) { 59 | $allParts[] = implode( 60 | ',', 61 | array_map( 62 | fn (string $tag): string => match (true) { 63 | // Valid - tag filter contains the `@` prefix 64 | str_starts_with($tag, '@'), 65 | str_starts_with($tag, '~@') => $tag, 66 | // Invalid / legacy cases - insert the missing `@` prefix in the right place 67 | str_starts_with($tag, '~') => '~@' . substr($tag, 1), 68 | default => '@' . $tag, 69 | }, 70 | explode(',', $andTags), 71 | ), 72 | ); 73 | } 74 | 75 | return implode('&&', $allParts); 76 | } 77 | 78 | /** 79 | * Filters feature according to the filter. 80 | * 81 | * @return FeatureNode 82 | */ 83 | public function filterFeature(FeatureNode $feature) 84 | { 85 | $scenarios = []; 86 | foreach ($feature->getScenarios() as $scenario) { 87 | if (!$this->isScenarioMatch($feature, $scenario)) { 88 | continue; 89 | } 90 | 91 | if ($scenario instanceof OutlineNode && $scenario->hasExamples()) { 92 | $exampleTables = []; 93 | 94 | foreach ($scenario->getExampleTables() as $exampleTable) { 95 | if ($this->isTagsMatchCondition(array_merge($feature->getTags(), $scenario->getTags(), $exampleTable->getTags()))) { 96 | $exampleTables[] = $exampleTable; 97 | } 98 | } 99 | 100 | $scenario = $scenario->withTables($exampleTables); 101 | } 102 | 103 | $scenarios[] = $scenario; 104 | } 105 | 106 | return $feature->withScenarios($scenarios); 107 | } 108 | 109 | /** 110 | * Checks if Feature matches specified filter. 111 | * 112 | * @param FeatureNode $feature Feature instance 113 | * 114 | * @return bool 115 | */ 116 | public function isFeatureMatch(FeatureNode $feature) 117 | { 118 | return $this->isTagsMatchCondition($feature->getTags()); 119 | } 120 | 121 | /** 122 | * Checks if scenario or outline matches specified filter. 123 | * 124 | * @param FeatureNode $feature Feature node instance 125 | * @param ScenarioInterface $scenario Scenario or Outline node instance 126 | * 127 | * @return bool 128 | */ 129 | public function isScenarioMatch(FeatureNode $feature, ScenarioInterface $scenario) 130 | { 131 | if ($scenario instanceof OutlineNode && $scenario->hasExamples()) { 132 | foreach ($scenario->getExampleTables() as $example) { 133 | if ($this->isTagsMatchCondition(array_merge($feature->getTags(), $scenario->getTags(), $example->getTags()))) { 134 | return true; 135 | } 136 | } 137 | 138 | return false; 139 | } 140 | 141 | return $this->isTagsMatchCondition(array_merge($feature->getTags(), $scenario->getTags())); 142 | } 143 | 144 | /** 145 | * Checks that node matches condition. 146 | * 147 | * @param array $tags 148 | * 149 | * @return bool 150 | */ 151 | protected function isTagsMatchCondition(array $tags) 152 | { 153 | if ($this->filterString === '') { 154 | return true; 155 | } 156 | 157 | // If the file was parsed in legacy mode, the `@` prefix will have been removed from the individual tags on the 158 | // parsed node. The tags in the filter expression still have their @ so we add the prefix back here if required. 159 | // This can be removed once legacy parsing mode is removed. 160 | $tags = array_map( 161 | static fn (string $tag) => str_starts_with($tag, '@') ? $tag : '@' . $tag, 162 | $tags 163 | ); 164 | 165 | foreach (explode('&&', $this->filterString) as $andTags) { 166 | $satisfiesComma = false; 167 | 168 | foreach (explode(',', $andTags) as $tag) { 169 | if ($tag[0] === '~') { 170 | $tag = mb_substr($tag, 1, mb_strlen($tag, 'utf8') - 1, 'utf8'); 171 | $satisfiesComma = !in_array($tag, $tags, true) || $satisfiesComma; 172 | } else { 173 | $satisfiesComma = in_array($tag, $tags, true) || $satisfiesComma; 174 | } 175 | } 176 | 177 | if (!$satisfiesComma) { 178 | return false; 179 | } 180 | } 181 | 182 | return true; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/Node/ExampleNode.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | /** 14 | * Represents Gherkin Outline Example. 15 | * 16 | * @author Konstantin Kudryashov 17 | * 18 | * @final since 4.15.0 19 | */ 20 | class ExampleNode implements ScenarioInterface, NamedScenarioInterface 21 | { 22 | use TaggedNodeTrait; 23 | 24 | /** 25 | * @var list|null 26 | */ 27 | private ?array $steps = null; 28 | 29 | /** 30 | * @param string $text The entire row as a string, e.g. "| 1 | 2 | 3 |" 31 | * @param list $tags 32 | * @param array $outlineSteps 33 | * @param array $tokens 34 | * @param int $line line number within the feature file 35 | * @param string|null $outlineTitle original title of the scenario outline 36 | * @param int|null $index the 1-based index of the row/example within the scenario outline 37 | */ 38 | public function __construct( 39 | private readonly string $text, 40 | private readonly array $tags, 41 | private readonly array $outlineSteps, 42 | private readonly array $tokens, 43 | private readonly int $line, 44 | private readonly ?string $outlineTitle = null, 45 | private readonly ?int $index = null, 46 | ) { 47 | } 48 | 49 | /** 50 | * Returns node type string. 51 | * 52 | * @return string 53 | */ 54 | public function getNodeType() 55 | { 56 | return 'Example'; 57 | } 58 | 59 | /** 60 | * Returns node keyword. 61 | * 62 | * @return string 63 | */ 64 | public function getKeyword() 65 | { 66 | return $this->getNodeType(); 67 | } 68 | 69 | /** 70 | * Returns the example row as a single string. 71 | * 72 | * @return string 73 | * 74 | * @deprecated you should normally not depend on the original row text, but if you really do, please switch 75 | * to {@see self::getExampleText()} as this method will be removed in the next major version 76 | */ 77 | public function getTitle() 78 | { 79 | return $this->text; 80 | } 81 | 82 | public function getTags() 83 | { 84 | return $this->tags; 85 | } 86 | 87 | /** 88 | * Checks if outline has steps. 89 | * 90 | * @return bool 91 | */ 92 | public function hasSteps() 93 | { 94 | return count($this->outlineSteps) > 0; 95 | } 96 | 97 | /** 98 | * Returns outline steps. 99 | * 100 | * @return list 101 | */ 102 | public function getSteps() 103 | { 104 | return $this->steps ??= $this->createExampleSteps(); 105 | } 106 | 107 | /** 108 | * Returns example tokens. 109 | * 110 | * @return string[] 111 | */ 112 | public function getTokens() 113 | { 114 | return $this->tokens; 115 | } 116 | 117 | /** 118 | * Returns outline declaration line number. 119 | * 120 | * @return int 121 | */ 122 | public function getLine() 123 | { 124 | return $this->line; 125 | } 126 | 127 | /** 128 | * Returns outline title. 129 | * 130 | * @return string|null 131 | */ 132 | public function getOutlineTitle() 133 | { 134 | return $this->outlineTitle; 135 | } 136 | 137 | /** 138 | * @todo Return type should become `string` in 5.0 when the class is actually `final` 139 | * 140 | * @phpstan-ignore return.unusedType 141 | */ 142 | public function getName(): ?string 143 | { 144 | return "{$this->replaceTextTokens($this->outlineTitle ?? '')} #{$this->index}"; 145 | } 146 | 147 | /** 148 | * Returns the example row as a single string. 149 | * 150 | * You should normally not need this, since it is an implementation detail. 151 | * If you need the individual example values, use {@see self::getTokens()}. 152 | * To get the fully-normalised/expanded title, use {@see self::getName()}. 153 | */ 154 | public function getExampleText(): string 155 | { 156 | return $this->text; 157 | } 158 | 159 | /** 160 | * Creates steps for this example from abstract outline steps. 161 | * 162 | * @return list 163 | */ 164 | protected function createExampleSteps() 165 | { 166 | $steps = []; 167 | foreach ($this->outlineSteps as $outlineStep) { 168 | $keyword = $outlineStep->getKeyword(); 169 | $keywordType = $outlineStep->getKeywordType(); 170 | $text = $this->replaceTextTokens($outlineStep->getText()); 171 | $args = $this->replaceArgumentsTokens($outlineStep->getArguments()); 172 | $line = $outlineStep->getLine(); 173 | 174 | $steps[] = new StepNode($keyword, $text, $args, $line, $keywordType); 175 | } 176 | 177 | return $steps; 178 | } 179 | 180 | /** 181 | * Replaces tokens in arguments with row values. 182 | * 183 | * @param array $arguments 184 | * 185 | * @return array 186 | */ 187 | protected function replaceArgumentsTokens(array $arguments) 188 | { 189 | foreach ($arguments as $num => $argument) { 190 | if ($argument instanceof TableNode) { 191 | $arguments[$num] = $this->replaceTableArgumentTokens($argument); 192 | } 193 | if ($argument instanceof PyStringNode) { 194 | $arguments[$num] = $this->replacePyStringArgumentTokens($argument); 195 | } 196 | } 197 | 198 | return $arguments; 199 | } 200 | 201 | /** 202 | * Replaces tokens in table with row values. 203 | * 204 | * @return TableNode 205 | */ 206 | protected function replaceTableArgumentTokens(TableNode $argument) 207 | { 208 | $replacedTable = []; 209 | foreach ($argument->getTable() as $line => $row) { 210 | $replacedRow = []; 211 | foreach ($row as $value) { 212 | $replacedRow[] = $this->replaceTextTokens($value); 213 | } 214 | $replacedTable[$line] = $replacedRow; 215 | } 216 | 217 | return new TableNode($replacedTable); 218 | } 219 | 220 | /** 221 | * Replaces tokens in PyString with row values. 222 | * 223 | * @return PyStringNode 224 | */ 225 | protected function replacePyStringArgumentTokens(PyStringNode $argument) 226 | { 227 | $strings = $argument->getStrings(); 228 | foreach ($strings as $line => $string) { 229 | $strings[$line] = $this->replaceTextTokens($strings[$line]); 230 | } 231 | 232 | return new PyStringNode($strings, $argument->getLine()); 233 | } 234 | 235 | /** 236 | * Replaces tokens in text with row values. 237 | * 238 | * @return string 239 | */ 240 | protected function replaceTextTokens(string $text) 241 | { 242 | foreach ($this->tokens as $key => $val) { 243 | $text = str_replace('<' . $key . '>', $val, $text); 244 | } 245 | 246 | return $text; 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/Loader/CucumberNDJsonAstLoader.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Loader; 12 | 13 | use Behat\Gherkin\Exception\NodeException; 14 | use Behat\Gherkin\Node\ArgumentInterface; 15 | use Behat\Gherkin\Node\BackgroundNode; 16 | use Behat\Gherkin\Node\ExampleTableNode; 17 | use Behat\Gherkin\Node\FeatureNode; 18 | use Behat\Gherkin\Node\OutlineNode; 19 | use Behat\Gherkin\Node\PyStringNode; 20 | use Behat\Gherkin\Node\ScenarioInterface; 21 | use Behat\Gherkin\Node\ScenarioNode; 22 | use Behat\Gherkin\Node\StepNode; 23 | use Behat\Gherkin\Node\TableNode; 24 | use RuntimeException; 25 | 26 | /** 27 | * Loads a feature from cucumber's messages JSON format. 28 | * 29 | * Lines in the ndjson file are expected to match the Cucumber Messages JSON schema defined at https://github.com/cucumber/messages/tree/main/jsonschema 30 | * 31 | * @deprecated This loader is deprecated and will be removed in 5.0 32 | * 33 | * @phpstan-type TLocation array{line: int, column?: int} 34 | * @phpstan-type TBackground array{location: TLocation, keyword: string, name: string, description: string, steps: list, id: string} 35 | * @phpstan-type TComment array{location: TLocation, text: string} 36 | * @phpstan-type TDataTable array{location: TLocation, rows: list} 37 | * @phpstan-type TDocString array{location: TLocation, content: string, delimiter: string, mediaType?: string} 38 | * @phpstan-type TExamples array{location: TLocation, tags: list, keyword: string, name: string, description: string, tableHeader?: TTableRow, tableBody: list, id: string} 39 | * @phpstan-type TFeature array{location: TLocation, tags: list, language: string, keyword: string, name: string, description: string, children: list} 40 | * @phpstan-type TFeatureChild array{background?: TBackground, scenario?: TScenario, rule?: TRule} 41 | * @phpstan-type TRule array{location: TLocation, tags: list, keyword: string, name: string, description: string, children: list, id: string} 42 | * @phpstan-type TRuleChild array{background?: TBackground, scenario?: TScenario} 43 | * @phpstan-type TScenario array{location: TLocation, tags: list, keyword: string, name: string, description: string, steps: list, examples: list, id: string} 44 | * @phpstan-type TStep array{location: TLocation, keyword: string, keywordType?: 'Unknown'|'Context'|'Action'|'Outcome'|'Conjunction', text: string, docString?: TDocString, dataTable?: TDataTable, id: string} 45 | * @phpstan-type TTableCell array{location: TLocation, value: string} 46 | * @phpstan-type TTableRow array{location: TLocation, cells: list, id: string} 47 | * @phpstan-type TTag array{location: TLocation, name: string, id: string} 48 | * @phpstan-type TGherkinDocument array{uri?: string, feature?: TFeature, comments: list} 49 | * // We only care about the gherkinDocument messages for our use case, so this does not describe the envelope fully 50 | * @phpstan-type TEnvelope array{gherkinDocument?: TGherkinDocument, ...} 51 | * 52 | * @extends AbstractLoader 53 | */ 54 | class CucumberNDJsonAstLoader extends AbstractLoader 55 | { 56 | public function supports(mixed $resource) 57 | { 58 | return is_string($resource); 59 | } 60 | 61 | protected function doLoad(mixed $resource): array 62 | { 63 | return array_values( 64 | array_filter( 65 | array_map( 66 | static function ($line) use ($resource) { 67 | // As we load data from the official Cucumber project, we assume the data matches the JSON schema. 68 | // @phpstan-ignore argument.type 69 | return self::getFeature(json_decode($line, true, 512, \JSON_THROW_ON_ERROR), $resource); 70 | }, 71 | file($resource) 72 | ?: throw new RuntimeException("Could not load Cucumber json file: $resource."), 73 | ) 74 | ) 75 | ); 76 | } 77 | 78 | /** 79 | * @phpstan-param TEnvelope $json 80 | */ 81 | private static function getFeature(array $json, string $filePath): ?FeatureNode 82 | { 83 | if (!isset($json['gherkinDocument']['feature'])) { 84 | return null; 85 | } 86 | 87 | $featureJson = $json['gherkinDocument']['feature']; 88 | 89 | return new FeatureNode( 90 | $featureJson['name'], 91 | $featureJson['description'], 92 | self::getTags($featureJson), 93 | self::getBackground($featureJson), 94 | self::getScenarios($featureJson), 95 | $featureJson['keyword'], 96 | $featureJson['language'], 97 | preg_replace('/(?<=\\.feature).*$/', '', $filePath), 98 | $featureJson['location']['line'] 99 | ); 100 | } 101 | 102 | /** 103 | * @phpstan-param array{tags: list, ...} $json 104 | * 105 | * @return list 106 | */ 107 | private static function getTags(array $json): array 108 | { 109 | return array_map( 110 | static fn (array $tag) => preg_replace('/^@/', '', $tag['name']) ?? $tag['name'], 111 | $json['tags'] 112 | ); 113 | } 114 | 115 | /** 116 | * @phpstan-param TFeature $json 117 | * 118 | * @return list 119 | */ 120 | private static function getScenarios(array $json): array 121 | { 122 | return array_values( 123 | array_map( 124 | static function ($child) { 125 | $tables = self::getTables($child['scenario']['examples']); 126 | 127 | if ($tables) { 128 | return new OutlineNode( 129 | $child['scenario']['name'], 130 | self::getTags($child['scenario']), 131 | self::getSteps($child['scenario']['steps']), 132 | $tables, 133 | $child['scenario']['keyword'], 134 | $child['scenario']['location']['line'] 135 | ); 136 | } 137 | 138 | return new ScenarioNode( 139 | $child['scenario']['name'], 140 | self::getTags($child['scenario']), 141 | self::getSteps($child['scenario']['steps']), 142 | $child['scenario']['keyword'], 143 | $child['scenario']['location']['line'] 144 | ); 145 | }, 146 | array_filter( 147 | $json['children'], 148 | static function ($child) { 149 | return isset($child['scenario']); 150 | } 151 | ) 152 | ) 153 | ); 154 | } 155 | 156 | /** 157 | * @phpstan-param TFeature $json 158 | */ 159 | private static function getBackground(array $json): ?BackgroundNode 160 | { 161 | $backgrounds = array_filter( 162 | $json['children'], 163 | static fn ($child) => isset($child['background']), 164 | ); 165 | 166 | if (count($backgrounds) !== 1) { 167 | return null; 168 | } 169 | 170 | $background = array_shift($backgrounds); 171 | 172 | return new BackgroundNode( 173 | $background['background']['name'], 174 | self::getSteps($background['background']['steps']), 175 | $background['background']['keyword'], 176 | $background['background']['location']['line'] 177 | ); 178 | } 179 | 180 | /** 181 | * @phpstan-param list $items 182 | * 183 | * @return list 184 | */ 185 | private static function getSteps(array $items): array 186 | { 187 | return array_map( 188 | static fn (array $item) => new StepNode( 189 | trim($item['keyword']), 190 | $item['text'], 191 | self::getStepArguments($item), 192 | $item['location']['line'], 193 | trim($item['keyword']) 194 | ), 195 | $items 196 | ); 197 | } 198 | 199 | /** 200 | * @phpstan-param TStep $step 201 | * 202 | * @return list 203 | */ 204 | private static function getStepArguments(array $step): array 205 | { 206 | $args = []; 207 | 208 | if (isset($step['docString'])) { 209 | $args[] = new PyStringNode( 210 | explode("\n", $step['docString']['content']), 211 | $step['docString']['location']['line'], 212 | ); 213 | } 214 | 215 | if (isset($step['dataTable'])) { 216 | $table = []; 217 | foreach ($step['dataTable']['rows'] as $row) { 218 | $table[$row['location']['line']] = array_column($row['cells'], 'value'); 219 | } 220 | $args[] = new TableNode($table); 221 | } 222 | 223 | return $args; 224 | } 225 | 226 | /** 227 | * @phpstan-param list $items 228 | * 229 | * @return list 230 | */ 231 | private static function getTables(array $items): array 232 | { 233 | return array_map( 234 | static function ($tableJson): ExampleTableNode { 235 | $headerRow = $tableJson['tableHeader'] ?? null; 236 | $tableBody = $tableJson['tableBody']; 237 | 238 | if ($headerRow === null && ($tableBody !== [])) { 239 | throw new NodeException( 240 | sprintf( 241 | 'Table header is required when a table body is provided for the example on line %s.', 242 | $tableJson['location']['line'], 243 | ) 244 | ); 245 | } 246 | 247 | $table = []; 248 | if ($headerRow !== null) { 249 | $table[$headerRow['location']['line']] = array_column($headerRow['cells'], 'value'); 250 | } 251 | 252 | foreach ($tableBody as $bodyRow) { 253 | $table[$bodyRow['location']['line']] = array_column($bodyRow['cells'], 'value'); 254 | } 255 | 256 | return new ExampleTableNode( 257 | $table, 258 | $tableJson['keyword'], 259 | self::getTags($tableJson) 260 | ); 261 | }, 262 | $items 263 | ); 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/Loader/ArrayLoader.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Loader; 12 | 13 | use Behat\Gherkin\Node\BackgroundNode; 14 | use Behat\Gherkin\Node\ExampleTableNode; 15 | use Behat\Gherkin\Node\FeatureNode; 16 | use Behat\Gherkin\Node\OutlineNode; 17 | use Behat\Gherkin\Node\PyStringNode; 18 | use Behat\Gherkin\Node\ScenarioNode; 19 | use Behat\Gherkin\Node\StepNode; 20 | use Behat\Gherkin\Node\TableNode; 21 | 22 | /** 23 | * From-array loader. 24 | * 25 | * @author Konstantin Kudryashov 26 | * 27 | * @phpstan-type TFeatureHash array{title?: string|null, description?: string|null, tags?: list, keyword?: string, language?: string, line?: int, background?: TBackgroundHash|null, scenarios?: array} 28 | * @phpstan-type TBackgroundHash array{title?: string|null, keyword?: string, line?: int, steps?: array} 29 | * @phpstan-type TScenarioHash array{title?: string|null, tags?: list, keyword?: string, line?: int, steps?: array} 30 | * @phpstan-type TOutlineHash array{type: 'outline', title?: string|null, tags?: list, keyword?: string, line?: int, steps?: array, examples?: TExampleTableHash|array} 31 | * @phpstan-type TExampleHash array{table: TExampleTableHash, tags?: list}|TExampleTableHash 32 | * @phpstan-type TExampleTableHash array, list> 33 | * @phpstan-type TStepHash array{keyword_type?: string, type?: string, text: string, keyword?: string, line?: int, arguments?: array} 34 | * @phpstan-type TArgumentHash array{type: 'table', rows: TTableHash}|TPyStringHash 35 | * @phpstan-type TTableHash array> 36 | * @phpstan-type TPyStringHash array{type: 'pystring', line?: int, text: string} 37 | * @phpstan-type TArrayResource array{feature: TFeatureHash}|array{features: array} 38 | * 39 | * @phpstan-extends AbstractLoader 40 | */ 41 | class ArrayLoader extends AbstractLoader 42 | { 43 | public function supports(mixed $resource) 44 | { 45 | return is_array($resource) && (isset($resource['features']) || isset($resource['feature'])); 46 | } 47 | 48 | protected function doLoad(mixed $resource): array 49 | { 50 | $features = []; 51 | 52 | if (isset($resource['features'])) { 53 | foreach ($resource['features'] as $iterator => $hash) { 54 | $feature = $this->loadFeatureHash($hash, $iterator); 55 | $features[] = $feature; 56 | } 57 | } elseif (isset($resource['feature'])) { 58 | $feature = $this->loadFeatureHash($resource['feature']); 59 | $features[] = $feature; 60 | } 61 | 62 | return $features; 63 | } 64 | 65 | /** 66 | * Loads feature from provided feature hash. 67 | * 68 | * @phpstan-param TFeatureHash $hash 69 | * 70 | * @return FeatureNode 71 | */ 72 | protected function loadFeatureHash(array $hash, int $line = 0) 73 | { 74 | $hash = array_merge( 75 | [ 76 | 'title' => null, 77 | 'description' => null, 78 | 'tags' => [], 79 | 'keyword' => 'Feature', 80 | 'language' => 'en', 81 | 'line' => $line, 82 | 'scenarios' => [], 83 | ], 84 | $hash 85 | ); 86 | $background = isset($hash['background']) ? $this->loadBackgroundHash($hash['background']) : null; 87 | 88 | $scenarios = []; 89 | foreach ((array) $hash['scenarios'] as $scenarioIterator => $scenarioHash) { 90 | if (isset($scenarioHash['type']) && $scenarioHash['type'] === 'outline') { 91 | $scenarios[] = $this->loadOutlineHash($scenarioHash, $scenarioIterator); 92 | } else { 93 | $scenarios[] = $this->loadScenarioHash($scenarioHash, $scenarioIterator); 94 | } 95 | } 96 | 97 | return new FeatureNode($hash['title'], $hash['description'], $hash['tags'], $background, $scenarios, $hash['keyword'], $hash['language'], null, $hash['line']); 98 | } 99 | 100 | /** 101 | * Loads background from provided hash. 102 | * 103 | * @phpstan-param TBackgroundHash $hash 104 | * 105 | * @return BackgroundNode 106 | */ 107 | protected function loadBackgroundHash(array $hash) 108 | { 109 | $hash = array_merge( 110 | [ 111 | 'title' => null, 112 | 'keyword' => 'Background', 113 | 'line' => 0, 114 | 'steps' => [], 115 | ], 116 | $hash 117 | ); 118 | 119 | $steps = $this->loadStepsHash($hash['steps']); 120 | 121 | return new BackgroundNode($hash['title'], $steps, $hash['keyword'], $hash['line']); 122 | } 123 | 124 | /** 125 | * Loads scenario from provided scenario hash. 126 | * 127 | * @phpstan-param TScenarioHash $hash 128 | * 129 | * @return ScenarioNode 130 | */ 131 | protected function loadScenarioHash(array $hash, int $line = 0) 132 | { 133 | $hash = array_merge( 134 | [ 135 | 'title' => null, 136 | 'tags' => [], 137 | 'keyword' => 'Scenario', 138 | 'line' => $line, 139 | 'steps' => [], 140 | ], 141 | $hash 142 | ); 143 | 144 | $steps = $this->loadStepsHash($hash['steps']); 145 | 146 | return new ScenarioNode($hash['title'], $hash['tags'], $steps, $hash['keyword'], $hash['line']); 147 | } 148 | 149 | /** 150 | * Loads outline from provided outline hash. 151 | * 152 | * @phpstan-param TOutlineHash $hash 153 | * 154 | * @return OutlineNode 155 | */ 156 | protected function loadOutlineHash(array $hash, int $line = 0) 157 | { 158 | $hash = array_merge( 159 | [ 160 | 'title' => null, 161 | 'tags' => [], 162 | 'keyword' => 'Scenario Outline', 163 | 'line' => $line, 164 | 'steps' => [], 165 | 'examples' => [], 166 | ], 167 | $hash 168 | ); 169 | 170 | $steps = $this->loadStepsHash($hash['steps']); 171 | 172 | if (isset($hash['examples']['keyword'])) { 173 | $examplesKeyword = $hash['examples']['keyword']; 174 | assert(is_string($examplesKeyword)); 175 | unset($hash['examples']['keyword']); 176 | } else { 177 | $examplesKeyword = 'Examples'; 178 | } 179 | 180 | $examples = $this->loadExamplesHash($hash['examples'], $examplesKeyword); 181 | 182 | return new OutlineNode($hash['title'], $hash['tags'], $steps, $examples, $hash['keyword'], $hash['line']); 183 | } 184 | 185 | /** 186 | * Loads steps from provided hash. 187 | * 188 | * @phpstan-param array $hash 189 | * 190 | * @return list 191 | */ 192 | private function loadStepsHash(array $hash) 193 | { 194 | $steps = []; 195 | foreach ($hash as $stepIterator => $stepHash) { 196 | $steps[] = $this->loadStepHash($stepHash, $stepIterator); 197 | } 198 | 199 | return $steps; 200 | } 201 | 202 | /** 203 | * Loads step from provided hash. 204 | * 205 | * @phpstan-param TStepHash $hash 206 | * 207 | * @return StepNode 208 | */ 209 | protected function loadStepHash(array $hash, int $line = 0) 210 | { 211 | $hash = array_merge( 212 | [ 213 | 'keyword_type' => 'Given', 214 | 'type' => 'Given', 215 | 'text' => null, 216 | 'keyword' => 'Scenario', 217 | 'line' => $line, 218 | 'arguments' => [], 219 | ], 220 | $hash 221 | ); 222 | 223 | $arguments = []; 224 | foreach ($hash['arguments'] as $argumentHash) { 225 | if ($argumentHash['type'] === 'table') { 226 | $arguments[] = $this->loadTableHash($argumentHash['rows']); 227 | } elseif ($argumentHash['type'] === 'pystring') { 228 | $arguments[] = $this->loadPyStringHash($argumentHash, $hash['line'] + 1); 229 | } 230 | } 231 | 232 | return new StepNode($hash['type'], $hash['text'], $arguments, $hash['line'], $hash['keyword_type']); 233 | } 234 | 235 | /** 236 | * Loads table from provided hash. 237 | * 238 | * @phpstan-param TTableHash $hash 239 | * 240 | * @return TableNode 241 | */ 242 | protected function loadTableHash(array $hash) 243 | { 244 | return new TableNode($hash); 245 | } 246 | 247 | /** 248 | * Loads PyString from provided hash. 249 | * 250 | * @phpstan-param TPyStringHash $hash 251 | * 252 | * @return PyStringNode 253 | */ 254 | protected function loadPyStringHash(array $hash, int $line = 0) 255 | { 256 | $line = $hash['line'] ?? $line; 257 | 258 | $strings = []; 259 | foreach (explode("\n", $hash['text']) as $string) { 260 | $strings[] = $string; 261 | } 262 | 263 | return new PyStringNode($strings, $line); 264 | } 265 | 266 | /** 267 | * Processes cases when examples are in the form of array of arrays 268 | * OR in the form of array of objects. 269 | * 270 | * @phpstan-param TExampleHash|array $examplesHash 271 | * 272 | * @return list 273 | */ 274 | private function loadExamplesHash(array $examplesHash, string $examplesKeyword): array 275 | { 276 | if (!isset($examplesHash[0])) { 277 | // examples as a single table - create a list with the one element 278 | // @phpstan-ignore argument.type 279 | return [new ExampleTableNode($examplesHash, $examplesKeyword)]; 280 | } 281 | 282 | $examples = []; 283 | 284 | foreach ($examplesHash as $exampleHash) { 285 | if (isset($exampleHash['table'])) { 286 | // we have examples as objects, hence there could be tags 287 | $exHashTags = $exampleHash['tags'] ?? []; 288 | // @phpstan-ignore argument.type,argument.type 289 | $examples[] = new ExampleTableNode($exampleHash['table'], $examplesKeyword, $exHashTags); 290 | } else { 291 | // we have examples as arrays 292 | // @phpstan-ignore argument.type 293 | $examples[] = new ExampleTableNode($exampleHash, $examplesKeyword); 294 | } 295 | } 296 | 297 | return $examples; 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /src/Node/TableNode.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Node; 12 | 13 | use ArrayIterator; 14 | use Behat\Gherkin\Exception\NodeException; 15 | use Iterator; 16 | use IteratorAggregate; 17 | use ReturnTypeWillChange; 18 | use Stringable; 19 | 20 | /** 21 | * Represents Gherkin Table argument. 22 | * 23 | * @author Konstantin Kudryashov 24 | * 25 | * @template-implements IteratorAggregate> 26 | */ 27 | class TableNode implements Stringable, ArgumentInterface, IteratorAggregate 28 | { 29 | /** 30 | * @var array 31 | */ 32 | private array $maxLineLength = []; 33 | 34 | /** 35 | * Initializes table. 36 | * 37 | * @param array> $table Table in form of [$rowLineNumber => [$val1, $val2, $val3]] 38 | * 39 | * @throws NodeException If the given table is invalid 40 | */ 41 | public function __construct( 42 | private array $table, 43 | ) { 44 | $columnCount = null; 45 | 46 | foreach ($this->getRows() as $rowIndex => $row) { 47 | if (!is_array($row)) { 48 | throw new NodeException(sprintf( 49 | "Table row '%s' is expected to be array, got %s", 50 | $rowIndex, 51 | gettype($row) 52 | )); 53 | } 54 | 55 | if ($columnCount === null) { 56 | $columnCount = count($row); 57 | } 58 | 59 | if (count($row) !== $columnCount) { 60 | throw new NodeException(sprintf( 61 | "Table row '%s' is expected to have %s columns, got %s", 62 | $rowIndex, 63 | $columnCount, 64 | count($row) 65 | )); 66 | } 67 | 68 | foreach ($row as $columnIndex => $cellValue) { 69 | if (!isset($this->maxLineLength[$columnIndex])) { 70 | $this->maxLineLength[$columnIndex] = 0; 71 | } 72 | 73 | if (!is_scalar($cellValue)) { 74 | throw new NodeException(sprintf( 75 | "Table cell at row '%s', column '%s' is expected to be scalar, got %s", 76 | $rowIndex, 77 | $columnIndex, 78 | get_debug_type($cellValue) 79 | )); 80 | } 81 | 82 | $this->maxLineLength[$columnIndex] = max($this->maxLineLength[$columnIndex], mb_strlen($cellValue, 'utf8')); 83 | } 84 | } 85 | } 86 | 87 | /** 88 | * Creates a table from a given list. 89 | * 90 | * @param array $list One-dimensional array 91 | * 92 | * @return TableNode 93 | * 94 | * @throws NodeException If the given list is not a one-dimensional array 95 | */ 96 | public static function fromList(array $list) 97 | { 98 | if (count($list) !== count($list, COUNT_RECURSIVE)) { 99 | throw new NodeException('List is not a one-dimensional array.'); 100 | } 101 | 102 | $table = array_map(fn ($item) => [$item], $list); 103 | 104 | return new self($table); 105 | } 106 | 107 | /** 108 | * Returns node type. 109 | * 110 | * @return string 111 | */ 112 | public function getNodeType() 113 | { 114 | return 'Table'; 115 | } 116 | 117 | /** 118 | * Returns table hash, formed by columns (ColumnsHash). 119 | * 120 | * @return list> 121 | */ 122 | public function getHash() 123 | { 124 | return $this->getColumnsHash(); 125 | } 126 | 127 | /** 128 | * Returns table hash, formed by columns. 129 | * 130 | * @return list> 131 | */ 132 | public function getColumnsHash() 133 | { 134 | $rows = $this->getRows(); 135 | $keys = array_shift($rows); 136 | 137 | $hash = []; 138 | foreach ($rows as $row) { 139 | assert($keys !== null); // If there is no first row due to an empty table, we won't enter this loop either. 140 | $hash[] = array_combine($keys, $row); 141 | } 142 | 143 | return $hash; 144 | } 145 | 146 | /** 147 | * Returns table hash, formed by rows. 148 | * 149 | * @return array> 150 | */ 151 | public function getRowsHash() 152 | { 153 | $hash = []; 154 | 155 | foreach ($this->getRows() as $row) { 156 | $hash[array_shift($row)] = count($row) === 1 ? $row[0] : $row; 157 | } 158 | 159 | return $hash; 160 | } 161 | 162 | /** 163 | * Returns numerated table lines. 164 | * Line numbers are keys, lines are values. 165 | * 166 | * @return array> 167 | */ 168 | public function getTable() 169 | { 170 | return $this->table; 171 | } 172 | 173 | /** 174 | * Returns table rows. 175 | * 176 | * @return list> 177 | */ 178 | public function getRows() 179 | { 180 | return array_values($this->table); 181 | } 182 | 183 | /** 184 | * Returns table definition lines. 185 | * 186 | * @return list 187 | */ 188 | public function getLines() 189 | { 190 | return array_keys($this->table); 191 | } 192 | 193 | /** 194 | * Returns specific row in a table. 195 | * 196 | * @param int $index Row number 197 | * 198 | * @return list 199 | * 200 | * @throws NodeException If row with specified index does not exist 201 | */ 202 | public function getRow(int $index) 203 | { 204 | $rows = $this->getRows(); 205 | 206 | if (!isset($rows[$index])) { 207 | throw new NodeException(sprintf('Rows #%d does not exist in table.', $index)); 208 | } 209 | 210 | return $rows[$index]; 211 | } 212 | 213 | /** 214 | * Returns specific column in a table. 215 | * 216 | * @param int $index Column number 217 | * 218 | * @return list 219 | * 220 | * @throws NodeException If column with specified index does not exist 221 | */ 222 | public function getColumn(int $index) 223 | { 224 | if ($index >= count($this->getRow(0))) { 225 | throw new NodeException(sprintf('Column #%d does not exist in table.', $index)); 226 | } 227 | 228 | $rows = $this->getRows(); 229 | $column = []; 230 | 231 | foreach ($rows as $row) { 232 | $column[] = $row[$index]; 233 | } 234 | 235 | return $column; 236 | } 237 | 238 | /** 239 | * Returns line number at which specific row was defined. 240 | * 241 | * @return int 242 | * 243 | * @throws NodeException If row with specified index does not exist 244 | */ 245 | public function getRowLine(int $index) 246 | { 247 | $lines = array_keys($this->table); 248 | 249 | if (!isset($lines[$index])) { 250 | throw new NodeException(sprintf('Rows #%d does not exist in table.', $index)); 251 | } 252 | 253 | return $lines[$index]; 254 | } 255 | 256 | /** 257 | * Converts row into delimited string. 258 | * 259 | * @param int $rowNum Row number 260 | * 261 | * @return string 262 | */ 263 | public function getRowAsString(int $rowNum) 264 | { 265 | $values = []; 266 | foreach ($this->getRow($rowNum) as $column => $value) { 267 | $values[] = $this->padRight(' ' . $value . ' ', $this->maxLineLength[$column] + 2); 268 | } 269 | 270 | return sprintf('|%s|', implode('|', $values)); 271 | } 272 | 273 | /** 274 | * Converts row into delimited string. 275 | * 276 | * @param int $rowNum Row number 277 | * @param callable(string, int): string $wrapper Wrapper function 278 | * 279 | * @return string 280 | */ 281 | public function getRowAsStringWithWrappedValues(int $rowNum, callable $wrapper) 282 | { 283 | $values = []; 284 | foreach ($this->getRow($rowNum) as $column => $value) { 285 | $value = $this->padRight(' ' . $value . ' ', $this->maxLineLength[$column] + 2); 286 | 287 | $values[] = call_user_func($wrapper, $value, $column); 288 | } 289 | 290 | return sprintf('|%s|', implode('|', $values)); 291 | } 292 | 293 | /** 294 | * Converts entire table into string. 295 | * 296 | * @return string 297 | */ 298 | public function getTableAsString() 299 | { 300 | $lines = []; 301 | $rowCount = count($this->getRows()); 302 | for ($i = 0; $i < $rowCount; ++$i) { 303 | $lines[] = $this->getRowAsString($i); 304 | } 305 | 306 | return implode("\n", $lines); 307 | } 308 | 309 | /** 310 | * Returns line number at which table was started. 311 | * 312 | * @return int 313 | */ 314 | public function getLine() 315 | { 316 | return $this->getRowLine(0); 317 | } 318 | 319 | /** 320 | * Converts table into string. 321 | * 322 | * @return string 323 | */ 324 | public function __toString() 325 | { 326 | return $this->getTableAsString(); 327 | } 328 | 329 | /** 330 | * Retrieves a hash iterator. 331 | * 332 | * @return Iterator 333 | */ 334 | #[ReturnTypeWillChange] 335 | public function getIterator() 336 | { 337 | return new ArrayIterator($this->getHash()); 338 | } 339 | 340 | /** 341 | * Obtains and adds rows from another table to the current table. 342 | * The second table should have the same structure as the current one. 343 | * 344 | * @return void 345 | * 346 | * @deprecated remove together with OutlineNode::getExampleTable 347 | */ 348 | public function mergeRowsFromTable(TableNode $node) 349 | { 350 | // check structure 351 | if ($this->getRow(0) !== $node->getRow(0)) { 352 | throw new NodeException('Tables have different structure. Cannot merge one into another'); 353 | } 354 | 355 | $firstLine = $node->getLine(); 356 | foreach ($node->getTable() as $line => $value) { 357 | if ($line === $firstLine) { 358 | continue; 359 | } 360 | 361 | $this->table[$line] = $value; 362 | } 363 | } 364 | 365 | /** 366 | * Pads string right. 367 | * 368 | * @return string 369 | */ 370 | protected function padRight(string $text, int $length) 371 | { 372 | while ($length > mb_strlen($text, 'utf8')) { 373 | $text .= ' '; 374 | } 375 | 376 | return $text; 377 | } 378 | } 379 | -------------------------------------------------------------------------------- /src/Keywords/KeywordsDumper.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin\Keywords; 12 | 13 | /** 14 | * Gherkin keywords dumper. 15 | * 16 | * @author Konstantin Kudryashov 17 | */ 18 | class KeywordsDumper 19 | { 20 | /** 21 | * @var callable(list, bool): string 22 | */ 23 | private $keywordsDumper; 24 | 25 | public function __construct( 26 | private readonly KeywordsInterface $keywords, 27 | ) { 28 | $this->keywordsDumper = [$this, 'dumpKeywords']; 29 | } 30 | 31 | /** 32 | * Sets keywords mapper function. 33 | * 34 | * Callable should accept 2 arguments (array $keywords and bool $isShort) 35 | * 36 | * @param callable(list, bool): string $mapper Mapper function 37 | * 38 | * @return void 39 | */ 40 | public function setKeywordsDumperFunction(callable $mapper) 41 | { 42 | $this->keywordsDumper = $mapper; 43 | } 44 | 45 | /** 46 | * Defaults keywords dumper. 47 | * 48 | * @param list $keywords Keywords list 49 | * @param bool $isShort Is short version 50 | * 51 | * @return string 52 | */ 53 | public function dumpKeywords(array $keywords, bool $isShort) 54 | { 55 | if ($isShort) { 56 | return count($keywords) > 1 57 | ? '(' . implode('|', $keywords) . ')' 58 | : $keywords[0]; 59 | } 60 | 61 | return $keywords[0]; 62 | } 63 | 64 | /** 65 | * Dumps keyworded feature into string. 66 | * 67 | * @param string $language Keywords language 68 | * @param bool $short Dump short version 69 | * 70 | * @return string|array String for short version and array of features for extended 71 | * 72 | * @phpstan-return ($short is true ? string : list) 73 | */ 74 | public function dump(string $language, bool $short = true, bool $excludeAsterisk = false) 75 | { 76 | $this->keywords->setLanguage($language); 77 | $languageComment = ''; 78 | if ($language !== 'en') { 79 | $languageComment = "# language: $language\n"; 80 | } 81 | 82 | $keywords = explode('|', $this->keywords->getFeatureKeywords()); 83 | 84 | if ($short) { 85 | $keywords = call_user_func($this->keywordsDumper, $keywords, $short); 86 | 87 | return trim($languageComment . $this->dumpFeature($keywords, $short, $excludeAsterisk)); 88 | } 89 | 90 | $features = []; 91 | foreach ($keywords as $keyword) { 92 | $keyword = call_user_func($this->keywordsDumper, [$keyword], $short); 93 | $features[] = trim($languageComment . $this->dumpFeature($keyword, $short, $excludeAsterisk)); 94 | } 95 | 96 | return $features; 97 | } 98 | 99 | /** 100 | * Dumps feature example. 101 | * 102 | * @param string $keyword Item keyword 103 | * @param bool $short Dump short version? 104 | * 105 | * @return string 106 | */ 107 | protected function dumpFeature(string $keyword, bool $short = true, bool $excludeAsterisk = false) 108 | { 109 | $dump = <<keywords->getBackgroundKeywords()); 120 | if ($short) { 121 | $keywords = call_user_func($this->keywordsDumper, $keywords, $short); 122 | $dump .= $this->dumpBackground($keywords, $short, $excludeAsterisk); 123 | } else { 124 | $keyword = call_user_func($this->keywordsDumper, [$keywords[0]], $short); 125 | $dump .= $this->dumpBackground($keyword, $short, $excludeAsterisk); 126 | } 127 | 128 | // Scenario 129 | $keywords = explode('|', $this->keywords->getScenarioKeywords()); 130 | if ($short) { 131 | $keywords = call_user_func($this->keywordsDumper, $keywords, $short); 132 | $dump .= $this->dumpScenario($keywords, $short, $excludeAsterisk); 133 | } else { 134 | foreach ($keywords as $keyword) { 135 | $keyword = call_user_func($this->keywordsDumper, [$keyword], $short); 136 | $dump .= $this->dumpScenario($keyword, $short, $excludeAsterisk); 137 | } 138 | } 139 | 140 | // Outline 141 | $keywords = explode('|', $this->keywords->getOutlineKeywords()); 142 | if ($short) { 143 | $keywords = call_user_func($this->keywordsDumper, $keywords, $short); 144 | $dump .= $this->dumpOutline($keywords, $short, $excludeAsterisk); 145 | } else { 146 | foreach ($keywords as $keyword) { 147 | $keyword = call_user_func($this->keywordsDumper, [$keyword], $short); 148 | $dump .= $this->dumpOutline($keyword, $short, $excludeAsterisk); 149 | } 150 | } 151 | 152 | return $dump; 153 | } 154 | 155 | /** 156 | * Dumps background example. 157 | * 158 | * @param string $keyword Item keyword 159 | * @param bool $short Dump short version? 160 | * 161 | * @return string 162 | */ 163 | protected function dumpBackground(string $keyword, bool $short = true, bool $excludeAsterisk = false) 164 | { 165 | $dump = <<dumpStep( 172 | $this->keywords->getGivenKeywords(), 173 | 'there is agent A', 174 | $short, 175 | $excludeAsterisk 176 | ); 177 | 178 | // And 179 | $dump .= $this->dumpStep( 180 | $this->keywords->getAndKeywords(), 181 | 'there is agent B', 182 | $short, 183 | $excludeAsterisk 184 | ); 185 | 186 | return $dump . "\n"; 187 | } 188 | 189 | /** 190 | * Dumps scenario example. 191 | * 192 | * @param string $keyword Item keyword 193 | * @param bool $short Dump short version? 194 | * 195 | * @return string 196 | */ 197 | protected function dumpScenario(string $keyword, bool $short = true, bool $excludeAsterisk = false) 198 | { 199 | $dump = <<dumpStep( 206 | $this->keywords->getGivenKeywords(), 207 | 'there is agent J', 208 | $short, 209 | $excludeAsterisk 210 | ); 211 | 212 | // And 213 | $dump .= $this->dumpStep( 214 | $this->keywords->getAndKeywords(), 215 | 'there is agent K', 216 | $short, 217 | $excludeAsterisk 218 | ); 219 | 220 | // When 221 | $dump .= $this->dumpStep( 222 | $this->keywords->getWhenKeywords(), 223 | 'I erase agent K\'s memory', 224 | $short, 225 | $excludeAsterisk 226 | ); 227 | 228 | // Then 229 | $dump .= $this->dumpStep( 230 | $this->keywords->getThenKeywords(), 231 | 'there should be agent J', 232 | $short, 233 | $excludeAsterisk 234 | ); 235 | 236 | // But 237 | $dump .= $this->dumpStep( 238 | $this->keywords->getButKeywords(), 239 | 'there should not be agent K', 240 | $short, 241 | $excludeAsterisk 242 | ); 243 | 244 | return $dump . "\n"; 245 | } 246 | 247 | /** 248 | * Dumps outline example. 249 | * 250 | * @param string $keyword Item keyword 251 | * @param bool $short Dump short version? 252 | * 253 | * @return string 254 | */ 255 | protected function dumpOutline(string $keyword, bool $short = true, bool $excludeAsterisk = false) 256 | { 257 | $dump = <<dumpStep( 264 | $this->keywords->getGivenKeywords(), 265 | 'there is agent ', 266 | $short, 267 | $excludeAsterisk 268 | ); 269 | 270 | // And 271 | $dump .= $this->dumpStep( 272 | $this->keywords->getAndKeywords(), 273 | 'there is agent ', 274 | $short, 275 | $excludeAsterisk 276 | ); 277 | 278 | // When 279 | $dump .= $this->dumpStep( 280 | $this->keywords->getWhenKeywords(), 281 | 'I erase agent \'s memory', 282 | $short, 283 | $excludeAsterisk 284 | ); 285 | 286 | // Then 287 | $dump .= $this->dumpStep( 288 | $this->keywords->getThenKeywords(), 289 | 'there should be agent ', 290 | $short, 291 | $excludeAsterisk 292 | ); 293 | 294 | // But 295 | $dump .= $this->dumpStep( 296 | $this->keywords->getButKeywords(), 297 | 'there should not be agent ', 298 | $short, 299 | $excludeAsterisk 300 | ); 301 | 302 | $keywords = explode('|', $this->keywords->getExamplesKeywords()); 303 | if ($short) { 304 | $keyword = call_user_func($this->keywordsDumper, $keywords, $short); 305 | } else { 306 | $keyword = call_user_func($this->keywordsDumper, [$keywords[0]], $short); 307 | } 308 | 309 | $dump .= <<keywordsDumper, $keywords, $short); 342 | $dump .= <<keywordsDumper, [$keyword], $short); 358 | $dump .= << 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Behat\Gherkin; 12 | 13 | use Behat\Gherkin\Exception\FilesystemException; 14 | use Behat\Gherkin\Exception\InvalidTagContentException; 15 | use Behat\Gherkin\Exception\LexerException; 16 | use Behat\Gherkin\Exception\NodeException; 17 | use Behat\Gherkin\Exception\ParserException; 18 | use Behat\Gherkin\Exception\UnexpectedParserNodeException; 19 | use Behat\Gherkin\Exception\UnexpectedTaggedNodeException; 20 | use Behat\Gherkin\Node\BackgroundNode; 21 | use Behat\Gherkin\Node\ExampleTableNode; 22 | use Behat\Gherkin\Node\FeatureNode; 23 | use Behat\Gherkin\Node\OutlineNode; 24 | use Behat\Gherkin\Node\PyStringNode; 25 | use Behat\Gherkin\Node\ScenarioInterface; 26 | use Behat\Gherkin\Node\ScenarioNode; 27 | use Behat\Gherkin\Node\StepNode; 28 | use Behat\Gherkin\Node\TableNode; 29 | use LogicException; 30 | 31 | /** 32 | * Gherkin parser. 33 | * 34 | * ``` 35 | * $lexer = new Behat\Gherkin\Lexer($keywords); 36 | * $parser = new Behat\Gherkin\Parser($lexer); 37 | * $featuresArray = $parser->parse('/path/to/feature.feature'); 38 | * ``` 39 | * 40 | * @author Konstantin Kudryashov 41 | * 42 | * @final since 4.15.0 43 | * 44 | * @phpstan-import-type TTokenType from Lexer 45 | * @phpstan-import-type TToken from Lexer 46 | * @phpstan-import-type TNullValueToken from Lexer 47 | * @phpstan-import-type TStringValueToken from Lexer 48 | * @phpstan-import-type TTagToken from Lexer 49 | * @phpstan-import-type TStepToken from Lexer 50 | * @phpstan-import-type TTitleToken from Lexer 51 | * @phpstan-import-type TTableRowToken from Lexer 52 | * @phpstan-import-type TTitleKeyword from Lexer 53 | * 54 | * @phpstan-type TParsedExpressionResult FeatureNode|BackgroundNode|ScenarioNode|OutlineNode|ExampleTableNode|TableNode|PyStringNode|StepNode|string 55 | */ 56 | class Parser implements ParserInterface 57 | { 58 | private string $input; 59 | private ?string $file = null; 60 | /** 61 | * @var list 62 | */ 63 | private array $tags = []; 64 | 65 | public function __construct( 66 | private readonly Lexer $lexer, 67 | private GherkinCompatibilityMode $compatibilityMode = GherkinCompatibilityMode::LEGACY, 68 | ) { 69 | } 70 | 71 | public function setGherkinCompatibilityMode(GherkinCompatibilityMode $mode): void 72 | { 73 | $this->compatibilityMode = $mode; 74 | } 75 | 76 | public function parse(string $input, ?string $file = null) 77 | { 78 | $this->input = $input; 79 | $this->file = $file; 80 | $this->tags = []; 81 | $this->lexer->setCompatibilityMode($this->compatibilityMode); 82 | 83 | try { 84 | $this->lexer->analyse($this->input); 85 | } catch (LexerException $e) { 86 | throw new ParserException( 87 | sprintf('Lexer exception "%s" thrown for file %s', $e->getMessage(), $file), 88 | 0, 89 | $e 90 | ); 91 | } 92 | 93 | $feature = null; 94 | while ($this->predictTokenType() !== 'EOS') { 95 | $node = $this->parseExpression(); 96 | 97 | if ($node === "\n" || $node === '') { 98 | continue; 99 | } 100 | 101 | if (!$feature && $node instanceof FeatureNode) { 102 | $feature = $node; 103 | continue; 104 | } 105 | 106 | throw new UnexpectedParserNodeException('Feature', $node, $this->file); 107 | } 108 | 109 | return $feature; 110 | } 111 | 112 | public function parseFile(string $file): ?FeatureNode 113 | { 114 | try { 115 | return $this->parse(Filesystem::readFile($file), $file); 116 | } catch (FilesystemException $ex) { 117 | throw new ParserException("Cannot parse file: {$ex->getMessage()}", previous: $ex); 118 | } 119 | } 120 | 121 | /** 122 | * Returns next token if it's type equals to expected. 123 | * 124 | * @phpstan-param TTokenType $type 125 | * 126 | * @return array 127 | * 128 | * @phpstan-return ( 129 | * $type is 'TableRow' 130 | * ? TTableRowToken 131 | * : ($type is 'Tag' 132 | * ? TTagToken 133 | * : ($type is 'Step' 134 | * ? TStepToken 135 | * : ($type is 'Text' 136 | * ? TStringValueToken 137 | * : ($type is TTitleKeyword 138 | * ? TTitleToken 139 | * : TNullValueToken|TStringValueToken 140 | * ))))) 141 | * 142 | * @throws ParserException 143 | */ 144 | protected function expectTokenType(string $type) 145 | { 146 | if ($this->predictTokenType() === $type) { 147 | return $this->lexer->getAdvancedToken(); 148 | } 149 | 150 | $token = $this->lexer->predictToken(); 151 | 152 | throw new ParserException(sprintf( 153 | 'Expected %s token, but got %s on line: %d%s', 154 | $type, 155 | $this->predictTokenType(), 156 | $token['line'], 157 | $this->file ? ' in file: ' . $this->file : '' 158 | )); 159 | } 160 | 161 | /** 162 | * Returns next token if it's type equals to expected. 163 | * 164 | * @param string $type Token type 165 | * 166 | * @return array|null 167 | * 168 | * @phpstan-return TToken|null 169 | */ 170 | protected function acceptTokenType(string $type) 171 | { 172 | if ($type !== $this->predictTokenType()) { 173 | return null; 174 | } 175 | 176 | return $this->lexer->getAdvancedToken(); 177 | } 178 | 179 | /** 180 | * Returns next token type without real input reading (prediction). 181 | * 182 | * @return string 183 | * 184 | * @phpstan-return TTokenType 185 | */ 186 | protected function predictTokenType() 187 | { 188 | $token = $this->lexer->predictToken(); 189 | 190 | return $token['type']; 191 | } 192 | 193 | /** 194 | * Parses current expression & returns Node. 195 | * 196 | * @phpstan-return TParsedExpressionResult 197 | * 198 | * @throws ParserException 199 | */ 200 | protected function parseExpression() 201 | { 202 | $type = $this->predictTokenType(); 203 | 204 | while ($type === 'Comment') { 205 | $this->expectTokenType('Comment'); 206 | 207 | $type = $this->predictTokenType(); 208 | } 209 | 210 | return match ($type) { 211 | 'Feature' => $this->parseFeature(), 212 | 'Background' => $this->parseBackground(), 213 | 'Scenario' => $this->parseScenario(), 214 | 'Outline' => $this->parseOutline(), 215 | 'Examples' => $this->parseExamples(), 216 | 'TableRow' => $this->parseTable(), 217 | 'PyStringOp' => $this->parsePyString(), 218 | 'Step' => $this->parseStep(), 219 | 'Text' => $this->parseText(), 220 | 'Newline' => $this->parseNewline(), 221 | 'Tag' => $this->parseTags(), 222 | 'Language' => $this->parseLanguage(), 223 | 'EOS' => '', 224 | default => throw new ParserException(sprintf('Unknown token type: %s', $type)), 225 | }; 226 | } 227 | 228 | /** 229 | * Parses feature token & returns it's node. 230 | * 231 | * @return FeatureNode 232 | * 233 | * @throws ParserException 234 | */ 235 | protected function parseFeature() 236 | { 237 | $token = $this->expectTokenType('Feature'); 238 | 239 | ['title' => $title, 'description' => $description] = $this->parseTitleAndDescription($token); 240 | $tags = $this->popTags(); 241 | $background = null; 242 | $scenarios = []; 243 | $keyword = $token['keyword']; 244 | $language = $this->lexer->getLanguage(); 245 | $file = $this->file; 246 | $line = $token['line']; 247 | 248 | // Parse description, background, scenarios & outlines 249 | while ($this->predictTokenType() !== 'EOS') { 250 | $node = $this->parseExpression(); 251 | 252 | if ($node === "\n") { 253 | continue; 254 | } 255 | 256 | $isBackgroundAllowed = ($background === null && $scenarios === []); 257 | 258 | if ($isBackgroundAllowed && $node instanceof BackgroundNode) { 259 | $background = $node; 260 | continue; 261 | } 262 | 263 | if ($node instanceof ScenarioInterface) { 264 | $scenarios[] = $node; 265 | continue; 266 | } 267 | 268 | throw new UnexpectedParserNodeException( 269 | match ($isBackgroundAllowed) { 270 | true => 'Background, Scenario or Outline', 271 | false => 'Scenario or Outline', 272 | }, 273 | $node, 274 | $this->file, 275 | ); 276 | } 277 | 278 | return new FeatureNode( 279 | $title, 280 | $description, 281 | $tags, 282 | $background, 283 | $scenarios, 284 | $keyword, 285 | $language, 286 | $file, 287 | $line 288 | ); 289 | } 290 | 291 | /** 292 | * Parses background token & returns it's node. 293 | * 294 | * @return BackgroundNode 295 | * 296 | * @throws ParserException 297 | */ 298 | protected function parseBackground() 299 | { 300 | $token = $this->expectTokenType('Background'); 301 | 302 | $keyword = $token['keyword']; 303 | $line = $token['line']; 304 | ['title' => $title, 'description' => $description] = $this->parseTitleAndDescription($token); 305 | 306 | if (count($this->popTags()) !== 0) { 307 | // Should not be possible to happen, parseTags should have already picked this up. 308 | throw new UnexpectedTaggedNodeException($token, $this->file); 309 | } 310 | 311 | // Parse description and steps 312 | $steps = []; 313 | $allowedTokenTypes = ['Step', 'Newline', 'Text', 'Comment']; 314 | while (in_array($this->predictTokenType(), $allowedTokenTypes)) { 315 | // NB: Technically, we do not support `Text` inside this loop. However, there is no situation where `Text` 316 | // can be a direct child or immediately following a Scenario. Therefore, we consume it here as the most 317 | // logical context for throwing an UnexpectedParserNodeException. 318 | 319 | $node = $this->parseExpression(); 320 | 321 | if ($node instanceof StepNode) { 322 | $steps[] = $this->normalizeStepNodeKeywordType($node, $steps); 323 | continue; 324 | } 325 | 326 | if ($node === "\n") { 327 | continue; 328 | } 329 | 330 | throw new UnexpectedParserNodeException('Step', $node, $this->file); 331 | } 332 | 333 | return new BackgroundNode($title, $steps, $keyword, $line, $description); 334 | } 335 | 336 | /** 337 | * Parses scenario token & returns it's node. 338 | * 339 | * @return OutlineNode|ScenarioNode 340 | * 341 | * @throws ParserException 342 | */ 343 | protected function parseScenario() 344 | { 345 | return $this->parseScenarioOrOutlineBody($this->expectTokenType('Scenario')); 346 | } 347 | 348 | /** 349 | * Parses scenario outline token & returns it's node. 350 | * 351 | * @return OutlineNode|ScenarioNode 352 | * 353 | * @throws ParserException 354 | */ 355 | protected function parseOutline() 356 | { 357 | return $this->parseScenarioOrOutlineBody($this->expectTokenType('Outline')); 358 | } 359 | 360 | /** 361 | * @phpstan-param TTitleToken $token 362 | */ 363 | private function parseScenarioOrOutlineBody(array $token): OutlineNode|ScenarioNode 364 | { 365 | ['title' => $title, 'description' => $description] = $this->parseTitleAndDescription($token); 366 | $tags = $this->popTags(); 367 | $keyword = $token['keyword']; 368 | 369 | /** @var list $examples */ 370 | $examples = []; 371 | $line = $token['line']; 372 | $steps = []; 373 | 374 | while (in_array($nextTokenType = $this->predictTokenType(), ['Step', 'Examples', 'Newline', 'Text', 'Comment', 'Tag'])) { 375 | // NB: Technically, we do not support `Text` inside this loop. However, there is no situation where `Text` 376 | // can be a direct child or immediately following a Scenario. Therefore, we consume it here as the most 377 | // logical context for throwing an UnexpectedParserNodeException. 378 | 379 | if ($nextTokenType === 'Comment') { 380 | $this->lexer->skipPredictedToken(); 381 | continue; 382 | } 383 | 384 | if ($nextTokenType === 'Tag') { 385 | // The only thing inside a Scenario / Scenario Outline that can be tagged is an Examples table 386 | // Scan on to see what the tags are attached to - if it's not Examples then we must have reached the 387 | // end of this scenario and be about to start a new one. 388 | if ($this->validateAndGetNextTaggedNodeType() !== 'Examples') { 389 | break; 390 | } 391 | } 392 | 393 | $node = $this->parseExpression(); 394 | 395 | if ($node === "\n") { 396 | continue; 397 | } 398 | 399 | if ($examples === [] && $node instanceof StepNode) { 400 | // Steps are only allowed before the first Examples table (if any) 401 | $steps[] = $this->normalizeStepNodeKeywordType($node, $steps); 402 | continue; 403 | } 404 | 405 | if ($node instanceof ExampleTableNode) { 406 | // NB: It is valid to have a Scenario with Examples: but no Steps 407 | // It is also valid to have an Examples: with no table rows (this produces no actual examples) 408 | $examples[] = $node; 409 | continue; 410 | } 411 | 412 | throw new UnexpectedParserNodeException( 413 | match ($examples) { 414 | [] => 'Step, Examples table, or end of Scenario', 415 | default => 'Examples table or end of Scenario', 416 | }, 417 | $node, 418 | $this->file, 419 | ); 420 | } 421 | 422 | if ($examples !== []) { 423 | return new OutlineNode($title, $tags, $steps, $examples, $keyword, $line, $description); 424 | } 425 | 426 | return new ScenarioNode($title, $tags, $steps, $keyword, $line, $description); 427 | } 428 | 429 | /** 430 | * Peek ahead to find the node that the current tags belong to. 431 | * 432 | * @throws UnexpectedTaggedNodeException if there is not a taggable node 433 | */ 434 | private function validateAndGetNextTaggedNodeType(): string 435 | { 436 | $deferred = []; 437 | try { 438 | while (true) { 439 | $deferred[] = $next = $this->lexer->getAdvancedToken(); 440 | $nextType = $next['type']; 441 | 442 | if (in_array($nextType, ['Tag', 'Comment', 'Newline'], true)) { 443 | // These are the only node types allowed between tag node(s) and the node they are tagging 444 | continue; 445 | } 446 | 447 | if (in_array($nextType, ['Feature', 'Examples', 'Scenario', 'Outline'], true)) { 448 | // These are the only taggable node types 449 | return $nextType; 450 | } 451 | 452 | throw new UnexpectedTaggedNodeException($next, $this->file); 453 | } 454 | } finally { 455 | // Rewind the lexer back to where it was when we started scanning ahead 456 | foreach ($deferred as $token) { 457 | $this->lexer->deferToken($token); 458 | } 459 | } 460 | } 461 | 462 | /** 463 | * Parses step token & returns it's node. 464 | * 465 | * @return StepNode 466 | */ 467 | protected function parseStep() 468 | { 469 | $token = $this->expectTokenType('Step'); 470 | 471 | $arguments = []; 472 | while (in_array($predicted = $this->predictTokenType(), ['PyStringOp', 'TableRow', 'Newline', 'Comment'])) { 473 | if ($predicted === 'Comment' || $predicted === 'Newline') { 474 | $this->acceptTokenType($predicted); 475 | continue; 476 | } 477 | 478 | $node = $this->parseExpression(); 479 | 480 | if ($node instanceof PyStringNode || $node instanceof TableNode) { 481 | $arguments[] = $node; 482 | } 483 | } 484 | 485 | return new StepNode($token['value'], trim($token['text']), $arguments, $token['line'], $token['keyword_type']); 486 | } 487 | 488 | /** 489 | * Parses examples table node. 490 | * 491 | * @return ExampleTableNode 492 | */ 493 | protected function parseExamples() 494 | { 495 | $token = $this->expectTokenType('Examples'); 496 | $keyword = $token['keyword']; 497 | $tags = empty($this->tags) ? [] : $this->popTags(); 498 | ['title' => $title, 'description' => $description] = $this->parseTitleAndDescription($token); 499 | $table = $this->parseTableRows(); 500 | 501 | try { 502 | return new ExampleTableNode($table, $keyword, $tags, $title, $description); 503 | } catch (NodeException $e) { 504 | $this->rethrowNodeException($e); 505 | } 506 | } 507 | 508 | /** 509 | * Parses table token & returns it's node. 510 | * 511 | * @return TableNode 512 | */ 513 | protected function parseTable() 514 | { 515 | $table = $this->parseTableRows(); 516 | 517 | try { 518 | return new TableNode($table); 519 | } catch (NodeException $e) { 520 | $this->rethrowNodeException($e); 521 | } 522 | } 523 | 524 | /** 525 | * Parses PyString token & returns it's node. 526 | * 527 | * @return PyStringNode 528 | */ 529 | protected function parsePyString() 530 | { 531 | $token = $this->expectTokenType('PyStringOp'); 532 | 533 | $line = $token['line']; 534 | 535 | $strings = []; 536 | while ('PyStringOp' !== ($predicted = $this->predictTokenType()) && $predicted === 'Text') { 537 | $strings[] = $this->expectTokenType('Text')['value']; 538 | } 539 | 540 | $this->expectTokenType('PyStringOp'); 541 | 542 | return new PyStringNode($strings, $line); 543 | } 544 | 545 | /** 546 | * Parses tags. 547 | * 548 | * @return string 549 | */ 550 | protected function parseTags() 551 | { 552 | $token = $this->expectTokenType('Tag'); 553 | 554 | // Validate that the tags are followed by a node that can be tagged 555 | $this->validateAndGetNextTaggedNodeType(); 556 | 557 | $this->guardTags($token['tags']); 558 | 559 | $this->tags = array_merge($this->tags, $token['tags']); 560 | 561 | return "\n"; 562 | } 563 | 564 | /** 565 | * Returns current set of tags and clears tag buffer. 566 | * 567 | * @return list 568 | */ 569 | protected function popTags() 570 | { 571 | $tags = $this->tags; 572 | $this->tags = []; 573 | 574 | return $tags; 575 | } 576 | 577 | /** 578 | * Checks the tags fit the required format. 579 | * 580 | * @param array $tags 581 | * 582 | * @return void 583 | */ 584 | protected function guardTags(array $tags) 585 | { 586 | foreach ($tags as $tag) { 587 | if (preg_match('/\s/', $tag)) { 588 | if ($this->compatibilityMode->shouldThrowOnWhitespaceInTag()) { 589 | throw new InvalidTagContentException($tag, $this->file); 590 | } 591 | 592 | trigger_error( 593 | sprintf('Whitespace in tags is deprecated, found "%s" in %s', $tag, $this->file ?? 'unknown file'), 594 | E_USER_DEPRECATED 595 | ); 596 | } 597 | } 598 | } 599 | 600 | /** 601 | * Parses next text line & returns it. 602 | * 603 | * @return string 604 | */ 605 | protected function parseText() 606 | { 607 | $token = $this->expectTokenType('Text'); 608 | \assert(\is_string($token['value'])); 609 | 610 | return $token['value']; 611 | } 612 | 613 | /** 614 | * Parses next newline & returns \n. 615 | * 616 | * @return string 617 | */ 618 | protected function parseNewline() 619 | { 620 | $this->expectTokenType('Newline'); 621 | 622 | return "\n"; 623 | } 624 | 625 | /** 626 | * Skips over language tags (they are handled inside the Lexer). 627 | * 628 | * @phpstan-return TParsedExpressionResult 629 | * 630 | * @throws ParserException 631 | * 632 | * @deprecated language tags are handled inside the Lexer, they skipped over (like any other comment) in the Parser 633 | */ 634 | protected function parseLanguage() 635 | { 636 | $this->expectTokenType('Language'); 637 | 638 | return $this->parseExpression(); 639 | } 640 | 641 | /** 642 | * Parses the rows of a table. 643 | * 644 | * @return array> 645 | */ 646 | private function parseTableRows(): array 647 | { 648 | $table = []; 649 | while (in_array($predicted = $this->predictTokenType(), ['TableRow', 'Newline', 'Comment'])) { 650 | if ($predicted === 'Comment' || $predicted === 'Newline') { 651 | $this->acceptTokenType($predicted); 652 | continue; 653 | } 654 | 655 | $token = $this->expectTokenType('TableRow'); 656 | 657 | $table[$token['line']] = $token['columns']; 658 | } 659 | 660 | return $table; 661 | } 662 | 663 | /** 664 | * @param TTitleToken $keywordToken 665 | * 666 | * @return array{title:string|null, description:string|null} 667 | */ 668 | private function parseTitleAndDescription(array $keywordToken): array 669 | { 670 | $originalTitle = trim($keywordToken['value'] ?? ''); 671 | $textLines = []; 672 | 673 | while (in_array($predicted = $this->predictTokenType(), ['Newline', 'Text', 'Comment'])) { 674 | $token = $this->expectTokenType($predicted); 675 | 676 | if ($token['type'] === 'Comment') { 677 | continue; 678 | } 679 | 680 | $text = match ($token['type']) { 681 | 'Newline' => '', 682 | 'Text' => $token['value'], 683 | default => throw new LogicException('Unexpected token type: ' . $token['type']), 684 | }; 685 | 686 | // The only time we use $token['value'] is if we got a `Text` token. 687 | // ->expectTokenType('Text') is tagged as returning a `TStringValueToken`, where 'value' cannot be null 688 | // However PHPStan cannot follow the chain through predictTokenType -> expectTokenType -> $token['type'] 689 | assert($text !== null, 'Text token value should not be null'); 690 | 691 | if ($this->compatibilityMode->shouldRemoveDescriptionPadding()) { 692 | $text = preg_replace('/^\s{0,' . ($keywordToken['indent'] + 2) . '}|\s*$/', '', $text); 693 | } 694 | 695 | $textLines[] = $text; 696 | } 697 | 698 | if ($this->compatibilityMode->allowAllNodeDescriptions()) { 699 | // Gherkin-compatible format - title is the original keyword value, description is all following lines. 700 | // Blank lines between title and description are removed, as are any after the description. 701 | return [ 702 | 'title' => $originalTitle ?: null, 703 | 'description' => trim(implode("\n", $textLines), "\n") ?: null, 704 | ]; 705 | } 706 | 707 | if ($keywordToken['type'] === 'Feature') { 708 | // Legacy format always supported a title & description for a Feature 709 | // But kept blank lines between title and description as the start of the description. 710 | return [ 711 | 'title' => $originalTitle ?: null, 712 | 'description' => rtrim(implode("\n", $textLines), "\n") ?: null, 713 | ]; 714 | } 715 | 716 | // Legacy format for nodes without description support - the full text block (title & description) is parsed 717 | // as the title. 718 | array_unshift($textLines, $originalTitle); 719 | 720 | return [ 721 | 'title' => rtrim(implode("\n", $textLines)) ?: null, 722 | 'description' => null, 723 | ]; 724 | } 725 | 726 | /** 727 | * Changes step node type for types But, And to type of previous step if it exists else sets to Given. 728 | * 729 | * @param StepNode[] $steps 730 | */ 731 | private function normalizeStepNodeKeywordType(StepNode $node, array $steps = []): StepNode 732 | { 733 | if (!in_array($node->getKeywordType(), ['And', 'But'])) { 734 | return $node; 735 | } 736 | 737 | if ($prev = end($steps)) { 738 | $keywordType = $prev->getKeywordType(); 739 | } else { 740 | $keywordType = 'Given'; 741 | } 742 | 743 | return new StepNode( 744 | $node->getKeyword(), 745 | $node->getText(), 746 | $node->getArguments(), 747 | $node->getLine(), 748 | $keywordType 749 | ); 750 | } 751 | 752 | private function rethrowNodeException(NodeException $e): never 753 | { 754 | throw new ParserException( 755 | $e->getMessage() . ($this->file ? ' in file ' . $this->file : ''), 756 | 0, 757 | $e 758 | ); 759 | } 760 | } 761 | --------------------------------------------------------------------------------