Initial commit

This commit is contained in:
ftc
2025-09-25 23:03:07 +03:00
commit fe417d9941
6627 changed files with 2153223 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"directory" : "vendor/bower-asset"
}
+33
View File
@@ -0,0 +1,33 @@
# phpstorm project files
.idea
# netbeans project files
nbproject
# zend studio for eclipse project files
.buildpath
.project
.settings
# windows thumbnail cache
Thumbs.db
# composer vendor dir
# /vendor
# composer itself is not needed
# composer.phar
# Mac DS_Store Files
.DS_Store
# phpunit itself is not needed
phpunit.phar
# local phpunit config
/phpunit.xml
tests/_output/*
tests/_support/_generated
#vagrant folder
/.vagrant
+29
View File
@@ -0,0 +1,29 @@
Copyright © 2008 by Yii Software LLC (http://www.yiisoft.com)
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of Yii Software LLC nor the names of its
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
+233
View File
@@ -0,0 +1,233 @@
<p align="center">
<a href="https://github.com/yiisoft" target="_blank">
<img src="https://avatars0.githubusercontent.com/u/993323" height="100px">
</a>
<h1 align="center">Yii 2 Basic Project Template</h1>
<br>
</p>
Yii 2 Basic Project Template is a skeleton [Yii 2](https://www.yiiframework.com/) application best for
rapidly creating small projects.
The template contains the basic features including user login/logout and a contact page.
It includes all commonly used configurations that would allow you to focus on adding new
features to your application.
[![Latest Stable Version](https://img.shields.io/packagist/v/yiisoft/yii2-app-basic.svg)](https://packagist.org/packages/yiisoft/yii2-app-basic)
[![Total Downloads](https://img.shields.io/packagist/dt/yiisoft/yii2-app-basic.svg)](https://packagist.org/packages/yiisoft/yii2-app-basic)
[![build](https://github.com/yiisoft/yii2-app-basic/workflows/build/badge.svg)](https://github.com/yiisoft/yii2-app-basic/actions?query=workflow%3Abuild)
DIRECTORY STRUCTURE
-------------------
assets/ contains assets definition
commands/ contains console commands (controllers)
config/ contains application configurations
controllers/ contains Web controller classes
mail/ contains view files for e-mails
models/ contains model classes
runtime/ contains files generated during runtime
tests/ contains various tests for the basic application
vendor/ contains dependent 3rd-party packages
views/ contains view files for the Web application
web/ contains the entry script and Web resources
REQUIREMENTS
------------
The minimum requirement by this project template that your Web server supports PHP 7.4.
INSTALLATION
------------
### Install via Composer
If you do not have [Composer](https://getcomposer.org/), you may install it by following the instructions
at [getcomposer.org](https://getcomposer.org/doc/00-intro.md#installation-nix).
You can then install this project template using the following command:
~~~
composer create-project --prefer-dist yiisoft/yii2-app-basic basic
~~~
Now you should be able to access the application through the following URL, assuming `basic` is the directory
directly under the Web root.
~~~
http://localhost/basic/web/
~~~
### Install from an Archive File
Extract the archive file downloaded from [yiiframework.com](https://www.yiiframework.com/download/) to
a directory named `basic` that is directly under the Web root.
Set cookie validation key in `config/web.php` file to some random secret string:
```php
'request' => [
// !!! insert a secret key in the following (if it is empty) - this is required by cookie validation
'cookieValidationKey' => '<secret random string goes here>',
],
```
You can then access the application through the following URL:
~~~
http://localhost/basic/web/
~~~
### Install with Docker
Update your vendor packages
docker-compose run --rm php composer update --prefer-dist
Run the installation triggers (creating cookie validation code)
docker-compose run --rm php composer install
Start the container
docker-compose up -d
You can then access the application through the following URL:
http://127.0.0.1:8000
**NOTES:**
- Minimum required Docker engine version `17.04` for development (see [Performance tuning for volume mounts](https://docs.docker.com/docker-for-mac/osxfs-caching/))
- The default configuration uses a host-volume in your home directory `.docker-composer` for composer caches
CONFIGURATION
-------------
### Database
Edit the file `config/db.php` with real data, for example:
```php
return [
'class' => 'yii\db\Connection',
'dsn' => 'mysql:host=localhost;dbname=yii2basic',
'username' => 'root',
'password' => '1234',
'charset' => 'utf8',
];
```
**NOTES:**
- Yii won't create the database for you, this has to be done manually before you can access it.
- Check and edit the other files in the `config/` directory to customize your application as required.
- Refer to the README in the `tests` directory for information specific to basic application tests.
TESTING
-------
Tests are located in `tests` directory. They are developed with [Codeception PHP Testing Framework](https://codeception.com/).
By default, there are 3 test suites:
- `unit`
- `functional`
- `acceptance`
Tests can be executed by running
```
vendor/bin/codecept run
```
The command above will execute unit and functional tests. Unit tests are testing the system components, while functional
tests are for testing user interaction. Acceptance tests are disabled by default as they require additional setup since
they perform testing in real browser.
### Running acceptance tests
To execute acceptance tests do the following:
1. Rename `tests/acceptance.suite.yml.example` to `tests/acceptance.suite.yml` to enable suite configuration
2. Replace `codeception/base` package in `composer.json` with `codeception/codeception` to install full-featured
version of Codeception
3. Update dependencies with Composer
```
composer update
```
4. Download [Selenium Server](https://www.seleniumhq.org/download/) and launch it:
```
java -jar ~/selenium-server-standalone-x.xx.x.jar
```
In case of using Selenium Server 3.0 with Firefox browser since v48 or Google Chrome since v53 you must download [GeckoDriver](https://github.com/mozilla/geckodriver/releases) or [ChromeDriver](https://sites.google.com/a/chromium.org/chromedriver/downloads) and launch Selenium with it:
```
# for Firefox
java -jar -Dwebdriver.gecko.driver=~/geckodriver ~/selenium-server-standalone-3.xx.x.jar
# for Google Chrome
java -jar -Dwebdriver.chrome.driver=~/chromedriver ~/selenium-server-standalone-3.xx.x.jar
```
As an alternative way you can use already configured Docker container with older versions of Selenium and Firefox:
```
docker run --net=host selenium/standalone-firefox:2.53.0
```
5. (Optional) Create `yii2basic_test` database and update it by applying migrations if you have them.
```
tests/bin/yii migrate
```
The database configuration can be found at `config/test_db.php`.
6. Start web server:
```
tests/bin/yii serve
```
7. Now you can run all available tests
```
# run all available tests
vendor/bin/codecept run
# run acceptance tests
vendor/bin/codecept run acceptance
# run only unit and functional tests
vendor/bin/codecept run unit,functional
```
### Code coverage support
By default, code coverage is disabled in `codeception.yml` configuration file, you should uncomment needed rows to be able
to collect code coverage. You can run your tests and collect coverage with the following command:
```
#collect coverage for all tests
vendor/bin/codecept run --coverage --coverage-html --coverage-xml
#collect coverage only for unit tests
vendor/bin/codecept run unit --coverage --coverage-html --coverage-xml
#collect coverage for unit and functional tests
vendor/bin/codecept run functional,unit --coverage --coverage-html --coverage-xml
```
You can see code coverage output under the `tests/_output` directory.
Vendored
+92
View File
@@ -0,0 +1,92 @@
require 'yaml'
require 'fileutils'
required_plugins_installed = nil
required_plugins = %w( vagrant-hostmanager vagrant-vbguest )
required_plugins.each do |plugin|
unless Vagrant.has_plugin? plugin
system "vagrant plugin install #{plugin}"
required_plugins_installed = true
end
end
# IF plugin[s] was just installed - restart required
if required_plugins_installed
# Get CLI command[s] and call again
system 'vagrant' + ARGV.to_s.gsub(/\[\"|\", \"|\"\]/, ' ')
exit
end
domains = {
app: 'yii2basic.test'
}
vagrantfile_dir_path = File.dirname(__FILE__)
config = {
local: vagrantfile_dir_path + '/vagrant/config/vagrant-local.yml',
example: vagrantfile_dir_path + '/vagrant/config/vagrant-local.example.yml'
}
# copy config from example if local config not exists
FileUtils.cp config[:example], config[:local] unless File.exist?(config[:local])
# read config
options = YAML.load_file config[:local]
# check github token
if options['github_token'].nil? || options['github_token'].to_s.length != 40
puts "You must place REAL GitHub token into configuration:\n/yii2-app-basic/vagrant/config/vagrant-local.yml"
exit
end
# vagrant configurate
Vagrant.configure(2) do |config|
# select the box
config.vm.box = 'bento/ubuntu-18.04'
# should we ask about box updates?
config.vm.box_check_update = options['box_check_update']
config.vm.provider 'virtualbox' do |vb|
# machine cpus count
vb.cpus = options['cpus']
# machine memory size
vb.memory = options['memory']
# machine name (for VirtualBox UI)
vb.name = options['machine_name']
end
# machine name (for vagrant console)
config.vm.define options['machine_name']
# machine name (for guest machine console)
config.vm.hostname = options['machine_name']
# network settings
config.vm.network 'private_network', ip: options['ip']
# sync: folder 'yii2-app-advanced' (host machine) -> folder '/app' (guest machine)
config.vm.synced_folder './', '/app', owner: 'vagrant', group: 'vagrant'
# disable folder '/vagrant' (guest machine)
config.vm.synced_folder '.', '/vagrant', disabled: true
# hosts settings (host machine)
config.vm.provision :hostmanager
config.hostmanager.enabled = true
config.hostmanager.manage_host = true
config.hostmanager.ignore_private_ip = false
config.hostmanager.include_offline = true
config.hostmanager.aliases = domains.values
# quick fix for failed guest additions installations
# config.vbguest.auto_update = false
# provisioners
config.vm.provision 'shell', path: './vagrant/provision/once-as-root.sh', args: [options['timezone'], options['ip']]
config.vm.provision 'shell', path: './vagrant/provision/once-as-vagrant.sh', args: [options['github_token']], privileged: false
config.vm.provision 'shell', path: './vagrant/provision/always-as-root.sh', run: 'always'
# post-install message (vagrant console)
config.vm.post_up_message = "App URL: http://#{domains[:app]}"
end
+31
View File
@@ -0,0 +1,31 @@
<?php
/**
* @link https://www.yiiframework.com/
* @copyright Copyright (c) 2008 Yii Software LLC
* @license https://www.yiiframework.com/license/
*/
namespace app\assets;
use yii\web\AssetBundle;
/**
* Main application asset bundle.
*
* @author Qiang Xue <qiang.xue@gmail.com>
* @since 2.0
*/
class AppAsset extends AssetBundle
{
public $basePath = '@webroot';
public $baseUrl = '@web';
public $css = [
'css/site.css',
];
public $js = [
];
public $depends = [
'yii\web\YiiAsset',
'yii\bootstrap5\BootstrapAsset'
];
}
+27
View File
@@ -0,0 +1,27 @@
actor: Tester
bootstrap: _bootstrap.php
paths:
tests: tests
output: tests/_output
data: tests/_data
helpers: tests/_support
settings:
memory_limit: 1024M
colors: true
modules:
config:
Yii2:
configFile: 'config/test.php'
# To enable code coverage:
#coverage:
# #c3_url: http://localhost:8080/index-test.php/
# enabled: true
# #remote: true
# #remote_config: '../codeception.yml'
# whitelist:
# include:
# - models/*
# - controllers/*
# - commands/*
# - mail/*
+34
View File
@@ -0,0 +1,34 @@
<?php
/**
* @link https://www.yiiframework.com/
* @copyright Copyright (c) 2008 Yii Software LLC
* @license https://www.yiiframework.com/license/
*/
namespace app\commands;
use yii\console\Controller;
use yii\console\ExitCode;
/**
* This command echoes the first argument that you have entered.
*
* This command is provided as an example for you to learn how to create console commands.
*
* @author Qiang Xue <qiang.xue@gmail.com>
* @since 2.0
*/
class HelloController extends Controller
{
/**
* This command echoes what you have entered as the message.
* @param string $message the message to be echoed.
* @return int Exit code
*/
public function actionIndex($message = 'hello world')
{
echo $message . "\n";
return ExitCode::OK;
}
}
+73
View File
@@ -0,0 +1,73 @@
{
"name": "yiisoft/yii2-app-basic",
"description": "Yii 2 Basic Project Template",
"keywords": ["yii2", "framework", "basic", "project template"],
"homepage": "https://www.yiiframework.com/",
"type": "project",
"license": "BSD-3-Clause",
"support": {
"issues": "https://github.com/yiisoft/yii2/issues?state=open",
"forum": "https://www.yiiframework.com/forum/",
"wiki": "https://www.yiiframework.com/wiki/",
"irc": "ircs://irc.libera.chat:6697/yii",
"source": "https://github.com/yiisoft/yii2"
},
"minimum-stability": "stable",
"require": {
"php": ">=7.4.0",
"yiisoft/yii2": "~2.0.45",
"yiisoft/yii2-bootstrap5": "~2.0.2",
"yiisoft/yii2-symfonymailer": "~2.0.3"
},
"require-dev": {
"yiisoft/yii2-debug": "~2.1.0",
"yiisoft/yii2-gii": "~2.2.0",
"yiisoft/yii2-faker": "~2.0.0",
"codeception/codeception": "^5.0.0 || ^4.0",
"codeception/lib-innerbrowser": "^4.0 || ^3.0 || ^1.1",
"codeception/module-asserts": "^3.0 || ^1.1",
"codeception/module-yii2": "^1.1",
"codeception/module-filesystem": "^3.0 || ^2.0 || ^1.1",
"codeception/verify": "^3.0 || ^2.2"
},
"config": {
"allow-plugins": {
"yiisoft/yii2-composer" : true
},
"process-timeout": 1800,
"fxp-asset": {
"enabled": false
}
},
"scripts": {
"post-install-cmd": [
"yii\\composer\\Installer::postInstall"
],
"post-create-project-cmd": [
"yii\\composer\\Installer::postCreateProject",
"yii\\composer\\Installer::postInstall"
]
},
"extra": {
"yii\\composer\\Installer::postCreateProject": {
"setPermission": [
{
"runtime": "0777",
"web/assets": "0777",
"yii": "0755"
}
]
},
"yii\\composer\\Installer::postInstall": {
"generateCookieValidationKey": [
"config/web.php"
]
}
},
"repositories": [
{
"type": "composer",
"url": "https://asset-packagist.org"
}
]
}
Generated
+5493
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+33
View File
@@ -0,0 +1,33 @@
<?php
/**
* This class only exists here for IDE (PHPStorm/Netbeans/...) autocompletion.
* This file is never included anywhere.
* Adjust this file to match classes configured in your application config, to enable IDE autocompletion for custom components.
* Example: A property phpdoc can be added in `__Application` class as `@property \vendor\package\Rollbar|__Rollbar $rollbar` and adding a class in this file
* ```php
* // @property of \vendor\package\Rollbar goes here
* class __Rollbar {
* }
* ```
*/
class Yii {
/**
* @var \yii\web\Application|\yii\console\Application|__Application
*/
public static $app;
}
/**
* @property yii\rbac\DbManager $authManager
* @property \yii\web\User|__WebUser $user
*
*/
class __Application {
}
/**
* @property app\models\User $identity
*/
class __WebUser {
}
+56
View File
@@ -0,0 +1,56 @@
<?php
$params = require __DIR__ . '/params.php';
$db = require __DIR__ . '/db.php';
$config = [
'id' => 'basic-console',
'basePath' => dirname(__DIR__),
'bootstrap' => ['log'],
'controllerNamespace' => 'app\commands',
'aliases' => [
'@bower' => '@vendor/bower-asset',
'@npm' => '@vendor/npm-asset',
'@tests' => '@app/tests',
],
'components' => [
'cache' => [
'class' => 'yii\caching\FileCache',
],
'log' => [
'targets' => [
[
'class' => 'yii\log\FileTarget',
'levels' => ['error', 'warning'],
],
],
],
'db' => $db,
],
'params' => $params,
/*
'controllerMap' => [
'fixture' => [ // Fixture generation command line.
'class' => 'yii\faker\FixtureController',
],
],
*/
];
if (YII_ENV_DEV) {
// configuration adjustments for 'dev' environment
$config['bootstrap'][] = 'gii';
$config['modules']['gii'] = [
'class' => 'yii\gii\Module',
];
// configuration adjustments for 'dev' environment
// requires version `2.1.21` of yii2-debug module
$config['bootstrap'][] = 'debug';
$config['modules']['debug'] = [
'class' => 'yii\debug\Module',
// uncomment the following to add your IP if you are not connecting from localhost.
//'allowedIPs' => ['127.0.0.1', '::1'],
];
}
return $config;
+14
View File
@@ -0,0 +1,14 @@
<?php
return [
'class' => 'yii\db\Connection',
'dsn' => 'mysql:host=localhost;dbname=yii2basic',
'username' => 'root',
'password' => '',
'charset' => 'utf8',
// Schema cache options (for production environment)
//'enableSchemaCache' => true,
//'schemaCacheDuration' => 60,
//'schemaCache' => 'cache',
];
+7
View File
@@ -0,0 +1,7 @@
<?php
return [
'adminEmail' => 'admin@example.com',
'senderEmail' => 'noreply@example.com',
'senderName' => 'Example.com mailer',
];
+46
View File
@@ -0,0 +1,46 @@
<?php
$params = require __DIR__ . '/params.php';
$db = require __DIR__ . '/test_db.php';
/**
* Application configuration shared by all test types
*/
return [
'id' => 'basic-tests',
'basePath' => dirname(__DIR__),
'aliases' => [
'@bower' => '@vendor/bower-asset',
'@npm' => '@vendor/npm-asset',
],
'language' => 'en-US',
'components' => [
'db' => $db,
'mailer' => [
'class' => \yii\symfonymailer\Mailer::class,
'viewPath' => '@app/mail',
// send all mails to a file by default.
'useFileTransport' => true,
'messageClass' => 'yii\symfonymailer\Message'
],
'assetManager' => [
'basePath' => __DIR__ . '/../web/assets',
],
'urlManager' => [
'showScriptName' => true,
],
'user' => [
'identityClass' => 'app\models\User',
],
'request' => [
'cookieValidationKey' => 'test',
'enableCsrfValidation' => false,
// but if you absolutely need it set cookie domain to localhost
/*
'csrfCookie' => [
'domain' => 'localhost',
],
*/
],
],
'params' => $params,
];
+6
View File
@@ -0,0 +1,6 @@
<?php
$db = require __DIR__ . '/db.php';
// test database! Important not to run tests on production or development databases
$db['dsn'] = 'mysql:host=localhost;dbname=yii2basic_test';
return $db;
+74
View File
@@ -0,0 +1,74 @@
<?php
$params = require __DIR__ . '/params.php';
$db = require __DIR__ . '/db.php';
$config = [
'id' => 'basic',
'basePath' => dirname(__DIR__),
'bootstrap' => ['log'],
'aliases' => [
'@bower' => '@vendor/bower-asset',
'@npm' => '@vendor/npm-asset',
],
'components' => [
'request' => [
// !!! insert a secret key in the following (if it is empty) - this is required by cookie validation
'cookieValidationKey' => 'wyfZNsArEJYqB8EzG5Ag7kphYr_KDZqb',
],
'cache' => [
'class' => 'yii\caching\FileCache',
],
'user' => [
'identityClass' => 'app\models\User',
'enableAutoLogin' => true,
],
'errorHandler' => [
'errorAction' => 'site/error',
],
'mailer' => [
'class' => \yii\symfonymailer\Mailer::class,
'viewPath' => '@app/mail',
// send all mails to a file by default.
'useFileTransport' => true,
],
'log' => [
'traceLevel' => YII_DEBUG ? 3 : 0,
'targets' => [
[
'class' => 'yii\log\FileTarget',
'levels' => ['error', 'warning'],
],
],
],
'db' => $db,
/*
'urlManager' => [
'enablePrettyUrl' => true,
'showScriptName' => false,
'rules' => [
],
],
*/
],
'params' => $params,
];
if (YII_ENV_DEV) {
// configuration adjustments for 'dev' environment
$config['bootstrap'][] = 'debug';
$config['modules']['debug'] = [
'class' => 'yii\debug\Module',
// uncomment the following to add your IP if you are not connecting from localhost.
//'allowedIPs' => ['127.0.0.1', '::1'],
];
$config['bootstrap'][] = 'gii';
$config['modules']['gii'] = [
'class' => 'yii\gii\Module',
// uncomment the following to add your IP if you are not connecting from localhost.
//'allowedIPs' => ['127.0.0.1', '::1'],
];
}
return $config;
+128
View File
@@ -0,0 +1,128 @@
<?php
namespace app\controllers;
use Yii;
use yii\filters\AccessControl;
use yii\web\Controller;
use yii\web\Response;
use yii\filters\VerbFilter;
use app\models\LoginForm;
use app\models\ContactForm;
class SiteController extends Controller
{
/**
* {@inheritdoc}
*/
public function behaviors()
{
return [
'access' => [
'class' => AccessControl::class,
'only' => ['logout'],
'rules' => [
[
'actions' => ['logout'],
'allow' => true,
'roles' => ['@'],
],
],
],
'verbs' => [
'class' => VerbFilter::class,
'actions' => [
'logout' => ['post'],
],
],
];
}
/**
* {@inheritdoc}
*/
public function actions()
{
return [
'error' => [
'class' => 'yii\web\ErrorAction',
],
'captcha' => [
'class' => 'yii\captcha\CaptchaAction',
'fixedVerifyCode' => YII_ENV_TEST ? 'testme' : null,
],
];
}
/**
* Displays homepage.
*
* @return string
*/
public function actionIndex()
{
return $this->render('index');
}
/**
* Login action.
*
* @return Response|string
*/
public function actionLogin()
{
if (!Yii::$app->user->isGuest) {
return $this->goHome();
}
$model = new LoginForm();
if ($model->load(Yii::$app->request->post()) && $model->login()) {
return $this->goBack();
}
$model->password = '';
return $this->render('login', [
'model' => $model,
]);
}
/**
* Logout action.
*
* @return Response
*/
public function actionLogout()
{
Yii::$app->user->logout();
return $this->goHome();
}
/**
* Displays contact page.
*
* @return Response|string
*/
public function actionContact()
{
$model = new ContactForm();
if ($model->load(Yii::$app->request->post()) && $model->contact(Yii::$app->params['adminEmail'])) {
Yii::$app->session->setFlash('contactFormSubmitted');
return $this->refresh();
}
return $this->render('contact', [
'model' => $model,
]);
}
/**
* Displays about page.
*
* @return string
*/
public function actionAbout()
{
return $this->render('about');
}
}
+9
View File
@@ -0,0 +1,9 @@
version: '2'
services:
php:
image: yiisoftware/yii2-php:7.4-apache
volumes:
- ~/.composer-docker/cache:/root/.composer/cache:delegated
- ./:/app:delegated
ports:
- '8000:80'
+22
View File
@@ -0,0 +1,22 @@
<?php
use yii\helpers\Html;
/** @var \yii\web\View $this view component instance */
/** @var \yii\mail\MessageInterface $message the message being composed */
/** @var string $content main view render result */
?>
<?php $this->beginPage() ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=<?= Yii::$app->charset ?>" />
<title><?= Html::encode($this->title) ?></title>
<?php $this->head() ?>
</head>
<body>
<?php $this->beginBody() ?>
<?= $content ?>
<?php $this->endBody() ?>
</body>
</html>
<?php $this->endPage() ?>
+13
View File
@@ -0,0 +1,13 @@
<?php
/**
* @var yii\web\View $this view component instance
* @var yii\mail\BaseMessage $message the message being composed
* @var string $content main view render result
*/
$this->beginPage();
$this->beginBody();
echo $content;
$this->endBody();
$this->endPage();
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace app\models;
use Yii;
use yii\base\Model;
/**
* ContactForm is the model behind the contact form.
*/
class ContactForm extends Model
{
public $name;
public $email;
public $subject;
public $body;
public $verifyCode;
/**
* @return array the validation rules.
*/
public function rules()
{
return [
// name, email, subject and body are required
[['name', 'email', 'subject', 'body'], 'required'],
// email has to be a valid email address
['email', 'email'],
// verifyCode needs to be entered correctly
['verifyCode', 'captcha'],
];
}
/**
* @return array customized attribute labels
*/
public function attributeLabels()
{
return [
'verifyCode' => 'Verification Code',
];
}
/**
* Sends an email to the specified email address using the information collected by this model.
* @param string $email the target email address
* @return bool whether the model passes validation
*/
public function contact($email)
{
if ($this->validate()) {
Yii::$app->mailer->compose()
->setTo($email)
->setFrom([Yii::$app->params['senderEmail'] => Yii::$app->params['senderName']])
->setReplyTo([$this->email => $this->name])
->setSubject($this->subject)
->setTextBody($this->body)
->send();
return true;
}
return false;
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace app\models;
use Yii;
use yii\base\Model;
/**
* LoginForm is the model behind the login form.
*
* @property-read User|null $user
*
*/
class LoginForm extends Model
{
public $username;
public $password;
public $rememberMe = true;
private $_user = false;
/**
* @return array the validation rules.
*/
public function rules()
{
return [
// username and password are both required
[['username', 'password'], 'required'],
// rememberMe must be a boolean value
['rememberMe', 'boolean'],
// password is validated by validatePassword()
['password', 'validatePassword'],
];
}
/**
* Validates the password.
* This method serves as the inline validation for password.
*
* @param string $attribute the attribute currently being validated
* @param array $params the additional name-value pairs given in the rule
*/
public function validatePassword($attribute, $params)
{
if (!$this->hasErrors()) {
$user = $this->getUser();
if (!$user || !$user->validatePassword($this->password)) {
$this->addError($attribute, 'Incorrect username or password.');
}
}
}
/**
* Logs in a user using the provided username and password.
* @return bool whether the user is logged in successfully
*/
public function login()
{
if ($this->validate()) {
return Yii::$app->user->login($this->getUser(), $this->rememberMe ? 3600*24*30 : 0);
}
return false;
}
/**
* Finds user by [[username]]
*
* @return User|null
*/
public function getUser()
{
if ($this->_user === false) {
$this->_user = User::findByUsername($this->username);
}
return $this->_user;
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace app\models;
class User extends \yii\base\BaseObject implements \yii\web\IdentityInterface
{
public $id;
public $username;
public $password;
public $authKey;
public $accessToken;
private static $users = [
'100' => [
'id' => '100',
'username' => 'admin',
'password' => 'admin',
'authKey' => 'test100key',
'accessToken' => '100-token',
],
'101' => [
'id' => '101',
'username' => 'demo',
'password' => 'demo',
'authKey' => 'test101key',
'accessToken' => '101-token',
],
];
/**
* {@inheritdoc}
*/
public static function findIdentity($id)
{
return isset(self::$users[$id]) ? new static(self::$users[$id]) : null;
}
/**
* {@inheritdoc}
*/
public static function findIdentityByAccessToken($token, $type = null)
{
foreach (self::$users as $user) {
if ($user['accessToken'] === $token) {
return new static($user);
}
}
return null;
}
/**
* Finds user by username
*
* @param string $username
* @return static|null
*/
public static function findByUsername($username)
{
foreach (self::$users as $user) {
if (strcasecmp($user['username'], $username) === 0) {
return new static($user);
}
}
return null;
}
/**
* {@inheritdoc}
*/
public function getId()
{
return $this->id;
}
/**
* {@inheritdoc}
*/
public function getAuthKey()
{
return $this->authKey;
}
/**
* {@inheritdoc}
*/
public function validateAuthKey($authKey)
{
return $this->authKey === $authKey;
}
/**
* Validates password
*
* @param string $password password to validate
* @return bool if password provided is valid for current user
*/
public function validatePassword($password)
{
return $this->password === $password;
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
/**
* Application requirement checker script.
*
* In order to run this script use the following console command:
* php requirements.php
*
* In order to run this script from the web, you should copy it to the web root.
* If you are using Linux you can create a hard link instead, using the following command:
* ln ../requirements.php requirements.php
*/
// you may need to adjust this path to the correct Yii framework path
// uncomment and adjust the following line if Yii is not located at the default path
//$frameworkPath = dirname(__FILE__) . '/vendor/yiisoft/yii2';
if (!isset($frameworkPath)) {
$searchPaths = array(
dirname(__FILE__) . '/vendor/yiisoft/yii2',
dirname(__FILE__) . '/../vendor/yiisoft/yii2',
);
foreach ($searchPaths as $path) {
if (is_dir($path)) {
$frameworkPath = $path;
break;
}
}
}
if (!isset($frameworkPath) || !is_dir($frameworkPath)) {
$message = "<h1>Error</h1>\n\n"
. "<p><strong>The path to yii framework seems to be incorrect.</strong></p>\n"
. '<p>You need to install Yii framework via composer or adjust the framework path in file <abbr title="' . __FILE__ . '">' . basename(__FILE__) . "</abbr>.</p>\n"
. '<p>Please refer to the <abbr title="' . dirname(__FILE__) . "/README.md\">README</abbr> on how to install Yii.</p>\n";
if (!empty($_SERVER['argv'])) {
// do not print HTML when used in console mode
echo strip_tags($message);
} else {
echo $message;
}
exit(1);
}
require_once($frameworkPath . '/requirements/YiiRequirementChecker.php');
$requirementsChecker = new YiiRequirementChecker();
$gdMemo = $imagickMemo = 'Either GD PHP extension with FreeType support or ImageMagick PHP extension with PNG support is required for image CAPTCHA.';
$gdOK = $imagickOK = false;
if (extension_loaded('imagick')) {
$imagick = new Imagick();
$imagickFormats = $imagick->queryFormats('PNG');
if (in_array('PNG', $imagickFormats)) {
$imagickOK = true;
} else {
$imagickMemo = 'Imagick extension should be installed with PNG support in order to be used for image CAPTCHA.';
}
}
if (extension_loaded('gd')) {
$gdInfo = gd_info();
if (!empty($gdInfo['FreeType Support'])) {
$gdOK = true;
} else {
$gdMemo = 'GD extension should be installed with FreeType support in order to be used for image CAPTCHA.';
}
}
/**
* Adjust requirements according to your application specifics.
*/
$requirements = array(
// Database :
array(
'name' => 'PDO extension',
'mandatory' => true,
'condition' => extension_loaded('pdo'),
'by' => 'All DB-related classes',
),
array(
'name' => 'PDO SQLite extension',
'mandatory' => false,
'condition' => extension_loaded('pdo_sqlite'),
'by' => 'All DB-related classes',
'memo' => 'Required for SQLite database.',
),
array(
'name' => 'PDO MySQL extension',
'mandatory' => false,
'condition' => extension_loaded('pdo_mysql'),
'by' => 'All DB-related classes',
'memo' => 'Required for MySQL database.',
),
array(
'name' => 'PDO PostgreSQL extension',
'mandatory' => false,
'condition' => extension_loaded('pdo_pgsql'),
'by' => 'All DB-related classes',
'memo' => 'Required for PostgreSQL database.',
),
// Cache :
array(
'name' => 'Memcache extension',
'mandatory' => false,
'condition' => extension_loaded('memcache') || extension_loaded('memcached'),
'by' => '<a href="https://www.yiiframework.com/doc-2.0/yii-caching-memcache.html">MemCache</a>',
'memo' => extension_loaded('memcached') ? 'To use memcached set <a href="https://www.yiiframework.com/doc-2.0/yii-caching-memcache.html#$useMemcached-detail">MemCache::useMemcached</a> to <code>true</code>.' : ''
),
// CAPTCHA:
array(
'name' => 'GD PHP extension with FreeType support',
'mandatory' => false,
'condition' => $gdOK,
'by' => '<a href="https://www.yiiframework.com/doc-2.0/yii-captcha-captcha.html">Captcha</a>',
'memo' => $gdMemo,
),
array(
'name' => 'ImageMagick PHP extension with PNG support',
'mandatory' => false,
'condition' => $imagickOK,
'by' => '<a href="https://www.yiiframework.com/doc-2.0/yii-captcha-captcha.html">Captcha</a>',
'memo' => $imagickMemo,
),
// PHP ini :
'phpExposePhp' => array(
'name' => 'Expose PHP',
'mandatory' => false,
'condition' => $requirementsChecker->checkPhpIniOff("expose_php"),
'by' => 'Security reasons',
'memo' => '"expose_php" should be disabled at php.ini',
),
'phpAllowUrlInclude' => array(
'name' => 'PHP allow url include',
'mandatory' => false,
'condition' => $requirementsChecker->checkPhpIniOff("allow_url_include"),
'by' => 'Security reasons',
'memo' => '"allow_url_include" should be disabled at php.ini',
),
'phpSmtp' => array(
'name' => 'PHP mail SMTP',
'mandatory' => false,
'condition' => strlen(ini_get('SMTP')) > 0,
'by' => 'Email sending',
'memo' => 'PHP mail SMTP server required',
),
);
// OPcache check
if (!version_compare(phpversion(), '5.5', '>=')) {
$requirements[] = array(
'name' => 'APC extension',
'mandatory' => false,
'condition' => extension_loaded('apc'),
'by' => '<a href="https://www.yiiframework.com/doc-2.0/yii-caching-apccache.html">ApcCache</a>',
);
}
$result = $requirementsChecker->checkYii()->check($requirements)->getResult();
$requirementsChecker->render();
exit($result['summary']['errors'] === 0 ? 0 : 1);
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+22
View File
@@ -0,0 +1,22 @@
<?php
// autoload.php @generated by Composer
if (PHP_VERSION_ID < 50600) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, $err);
} elseif (!headers_sent()) {
echo $err;
}
}
throw new RuntimeException($err);
}
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInit7309b9d2e4518ad52988e780a875afc0::getLoader();
+518
View File
@@ -0,0 +1,518 @@
# Change Log
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
This project follows the [Behat release and version support policies]
(https://docs.behat.org/en/latest/releases.html).
# [4.14.0] - 2025-05-23
### Changed
* Throw ParserException if file ends with tags by @acoulton in [#313](https://github.com/Behat/Gherkin/pull/313)
* Throw ParserException if Background comes after first Scenario by @acoulton in [#343](https://github.com/Behat/Gherkin/pull/343)
* For compatibility with the official cucumber/gherkin parsers, we now accept some gherkin syntax that would previously
have triggered a ParserException. Users may wish to consider running a tool like gherkin-lint in CI to detect
incomplete feature files or valid-but-unusual gherkin syntax. The specific changes are:
- Parse `Scenario` and `Scenario Outline` as synonyms depending on the presence (or not) of an `Examples:` keyword.
by @acoulton in [#316](https://github.com/Behat/Gherkin/pull/316) and [#324](https://github.com/Behat/Gherkin/pull/324)
- Do not throw on some unexpected Feature / Language tags by @acoulton in [#323](https://github.com/Behat/Gherkin/pull/323)
- Do not throw on `.feature` file that does not contain a Feature by @acoulton in [#340](https://github.com/Behat/Gherkin/pull/340)
- Ignore content after table right-hand `|` (instead of throwing) by @acoulton in [#341](https://github.com/Behat/Gherkin/pull/341)
* Remove the line length from the NewLine token value by @stof in [#338](https://github.com/Behat/Gherkin/pull/338)
* Added precise PHPStan type information by @stof in [#332](https://github.com/Behat/Gherkin/pull/332),
[#333](https://github.com/Behat/Gherkin/pull/333), [#339](https://github.com/Behat/Gherkin/pull/339)
and [#334](https://github.com/Behat/Gherkin/pull/334)
### Internal
* Make private props readonly; fix tests by @uuf6429 in [#319](https://github.com/Behat/Gherkin/pull/319)
* Use the `Yaml::parseFile` API to handle Yaml files by @stof in [#335](https://github.com/Behat/Gherkin/pull/335)
* test: Make CucumberND name reading consistent by @uuf6429 in [#309](https://github.com/Behat/Gherkin/pull/309)
* test: Use vfsStream to simplify / improve filesystem-related tests by @uuf6429 in [#298](https://github.com/Behat/Gherkin/pull/298)
* test: Handle optional tableHeader when loading NDJson examples by @uuf6429 in [#294](https://github.com/Behat/Gherkin/pull/294)
* test: Refactor valid ParserExceptionsTest examples into cucumber/gherkin testdata by @acoulton in [#322](https://github.com/Behat/Gherkin/pull/322)
* test: Compare step arguments when checking gherkin parity by @acoulton in [#325](https://github.com/Behat/Gherkin/pull/325)
* test: Use a custom object comparator to ignore the keywordType of StepNode by @stof in [#331](https://github.com/Behat/Gherkin/pull/331)
* ci: Add conventional title to gherkin update, error on missing asserts by @acoulton in [#314](https://github.com/Behat/Gherkin/pull/314)
* Assert that preg_split does not fail when splitting a table row by @stof in [#337](https://github.com/Behat/Gherkin/pull/337)
* Add assertions in the parser to reflect the structure of tokens by @stof in [#342](https://github.com/Behat/Gherkin/pull/342)
* style: Define and change phpdoc order coding style by @uuf6429 in [#345](https://github.com/Behat/Gherkin/pull/345)
# [4.13.0] - 2025-05-06
### Changed
* Files have been moved to flatten paths into a PSR-4 structure (instead of the previous PSR-0). This may affect users
who are requiring files directly rather than using the composer autoloader as expected.
See the 4.12.0 release for the new `CachedArrayKeywords::withDefaultKeywords()` to use the `i18n.php` file without
depending on paths to other files in this repo. By @uuf6429 in [#288](https://github.com/Behat/Gherkin/pull/288)
### Added
* ExampleTableNode now implements TaggedNodeInterface. Also refactored node tag handling methods. By @uuf6429 in
[#289](https://github.com/Behat/Gherkin/pull/289)
* Improve some exceptions thrown when parsing invalid feature files. Also increased test coverage. By @uuf6429 in
[#295](https://github.com/Behat/Gherkin/pull/295)
* New translations for `amh` (Amharic), `be` (Belarusian) and `ml` (Malayalam) from cucumber/gherkin in [#306](https://github.com/Behat/Gherkin/pull/306)
* Improved translations / whitespace for `ga` (Irish), `it` (Italian), `ja` (Japanese), `ka` (Georgian) and `ko` (Korean)
from cucumber/gherkin in [#306](https://github.com/Behat/Gherkin/pull/306)
### Internal
* Fix & improve automatic CI updates to newer cucumber/gherkin test data and translations. By @acoulton in
[#300](https://github.com/Behat/Gherkin/pull/300), [#302](https://github.com/Behat/Gherkin/pull/302),
[#304](https://github.com/Behat/Gherkin/pull/304), [#305](https://github.com/Behat/Gherkin/pull/305)
* Update code style and resolve PHPStan warnings (up to level 9) in tests and CI scripts. By @uuf6429 in
[#296](https://github.com/Behat/Gherkin/pull/296), [#297](https://github.com/Behat/Gherkin/pull/297)
and [#307](https://github.com/Behat/Gherkin/pull/307)
* Make tests that expect exceptions more explicit by @uuf6429 in [#310](https://github.com/Behat/Gherkin/pull/310)
* Improve CI workflows and integrate Codecov reporting by @uuf6429 in [#299](https://github.com/Behat/Gherkin/pull/299)
and [#301](https://github.com/Behat/Gherkin/pull/301)
* Refactor tag filtering implementation by @uuf6429 in [#308](https://github.com/Behat/Gherkin/pull/308)
* Update cucumber/gherkin parity tests to v32.1.1 in [#306](https://github.com/Behat/Gherkin/pull/306)
# [4.12.0] - 2025-02-26
### Changed
* Gherkin::VERSION is deprecated and will not be updated, use the composer runtime API if you need to identify the
running version. This also changes the value used to namespace cached feature files.
by @acoulton in [#279](https://github.com/Behat/Gherkin/pull/279)
### Added
* Provide `CachedArrayKeywords::withDefaultKeywords()` to create an instance without an external dependency on the path
to the `i18n.php` file in this repo. **NOTE** that paths to source files will change in the next Gherkin release -
use the new constructor to avoid any impact.
by @carlos-granados in [#290](https://github.com/Behat/Gherkin/pull/290)
### Internal
* Upgrade to phpunit 10 by @uuf6429 in [#275](https://github.com/Behat/Gherkin/pull/275)
* Remove redundant files by @uuf6429 in [#278](https://github.com/Behat/Gherkin/pull/278)
* Update documentation by @uuf6429 in [#274](https://github.com/Behat/Gherkin/pull/274)
* Adopt PHP CS Fixer and apply code styles by @uuf6429 in [#277](https://github.com/Behat/Gherkin/pull/277)
* Add PHPStan and improve / fix docblock annotations and type-safety within methods to achieve level 5 by
@uuf6429 in [#276](https://github.com/Behat/Gherkin/pull/276), [#281](https://github.com/Behat/Gherkin/pull/281),
[#282](https://github.com/Behat/Gherkin/pull/282), and [#287](https://github.com/Behat/Gherkin/pull/287)
# [4.11.0] - 2024-12-06
### Changed
* Drop support for PHP < 8.1, Symfony < 5.4 and Symfony 6.0 - 6.3. In future we will drop support for PHP and symfony
versions as they reach EOL. by @acoulton in [#272](https://github.com/Behat/Gherkin/pull/272)
* Deprecated `ExampleNode::getTitle()` and `ScenarioNode::getTitle()` in favour of new methods with clearer meaning.
by @uuf6429 in [#271](https://github.com/Behat/Gherkin/pull/271)
### Added
* Added `(ExampleNode|ScenarioNode)::getName()` to access human-readable names for examples and scenarios,
and `ExampleNode::getExampleText()` for the string content of the example table row.
by @uuf6429 in [#271](https://github.com/Behat/Gherkin/pull/271)
### Internal
* Enable dependabot for github actions workflows by @jrfnl in [#261](https://github.com/Behat/Gherkin/pull/261)
# 4.10.0 / 2024-10-19
### Changed
- **⚠ Backslashes in feature files must now be escaped**\
Gherkin syntax treats `\` as an escape character, which must be escaped (`\\`) to use it as a
literal value. Historically, this was not being parsed correctly. This release fixes that bug,
but means that if your scenarios currently use unescaped `\` you will need to replace each one
with `\\` to achieve the same parsed result.
By @everzet in 5a0836d.
### Added
- Symfony 6 and 7 thanks to @tacman in #257
- PHP 8.4 support thanks to @heiglandreas in #258 and @jrfnl in #262
### Fixed
- Fix exception when filter string is empty thanks to @magikid in #251
### Internal
- Sync teststuite with Cucumber 24.1.0
- Fix PHPUnit 10 deprecation messages
- A lot of great CI work by @heiglandreas and @jrfnl
# 4.9.0 / 2021-10-12
- Simplify the boolean condition for the tag matching by @stof in https://github.com/Behat/Gherkin/pull/219
- Remove symfony phpunit bridge by @ciaranmcnulty in https://github.com/Behat/Gherkin/pull/220
- Ignore the bin folder in archives by @stof in https://github.com/Behat/Gherkin/pull/226
- Cast table node exceptions into ParserExceptions when throwing by @ciaranmcnulty in https://github.com/Behat/Gherkin/pull/216
- Cucumber changelog in PRs and using correct hash by @ciaranmcnulty in https://github.com/Behat/Gherkin/pull/225
- Support alternative docstrings format (```) by @ciaranmcnulty in https://github.com/Behat/Gherkin/pull/214
- Fix DocBlocks (Boolean -> bool) by @simonhammes in https://github.com/Behat/Gherkin/pull/237
- Tag parsing by @ciaranmcnulty in https://github.com/Behat/Gherkin/pull/215
- Remove test - cucumber added an example with Rule which is not supported by @ciaranmcnulty in https://github.com/Behat/Gherkin/pull/239
- Add PHP 8.1 support by @javer in https://github.com/Behat/Gherkin/pull/242
- Fix main branch alias version by @mvorisek in https://github.com/Behat/Gherkin/pull/244
# 4.8.0 / 2021-02-04
- Drop support for PHP before version 7.2
# 4.7.3 / 2021-02-04
- Refactored comments parsing to avoid Maximum function nesting level errors
# 4.7.2 / 2021-02-03
- Issue where Scenario Outline title was not populated into Examples
- Updated translations from cucumber 16.0.0
# 4.7.1 / 2021-01-26
- Issue parsing comments before scenarios when following an Examples table
# 4.7.0 / 2021-01-24
- Provides better messages for TableNode construct errors
- Now allows single character steps
- Supports multiple Example Tables with tags
# 4.6.2 / 2020-03-17
- Fixed issues due to incorrect cache key
# 4.6.1 / 2020-02-27
- Fix AZ translations
- Correctly filter features, now that the base path is correctly set
# 4.6.0 / 2019-01-16
- Updated translations (including 'Example' as synonym for 'Scenario' in `en`)
# 4.5.1 / 2017-08-30
- Fix regression in `PathsFilter`
# 4.5.0 / 2017-08-30
- Sync i18n with Cucumber Gherkin
- Drop support for HHVM tests on Travis
- Add `TableNode::fromList()` method (thanks @TravisCarden)
- Add `ExampleNode::getOutlineTitle()` method (thanks @duxet)
- Use realpath, so the feature receives the cwd prefixed (thanks @glennunipro)
- Explicitly handle non-two-dimensional arrays in TableNode (thanks @TravisCarden)
- Fix to line/linefilter scenario runs which take relative paths to files (thanks @generalconsensus)
# 4.4.5 / 2016-10-30
- Fix partial paths matching in `PathsFilter`
# 4.4.4 / 2016-09-18
- Provide clearer exception for non-writeable cache directories
# 4.4.3 / 2016-09-18
- Ensure we reset tags between features
# 4.4.2 / 2016-09-03
- Sync 18n with gherkin 3
# 4.4.1 / 2015-12-30
- Ensure keywords are trimmed when syncing translations
- Sync 18n with cucumber
# 4.4.0 / 2015-09-19
- Added validation enforcing that all rows of a `TableNode` have the same number of columns
- Added `TableNode::getColumn` to get a column from the table
- Sync 18n with cucumber
# 4.3.0 / 2014-06-06
- Added `setFilters(array)` method to `Gherkin` class
- Added `NarrativeFilter` for non-english `RoleFilter` lovers
# 4.2.1 / 2014-06-06
- Fix parsing of features without line feed at the end
# 4.2.0 / 2014-05-27
- Added `getKeyword()` and `getKeywordType()` methods to `StepNode`, deprecated `getType()`.
Thanks to @kibao
# 4.1.3 / 2014-05-25
- Properly handle tables with rows terminating in whitespace
# 4.1.2 / 2014-05-14
- Handle case where Gherkin cache is broken
# 4.1.1 / 2014-05-05
- Fixed the compatibility with PHP 5.6-beta by avoiding to use the broken PHP array function
- The YamlFileLoader no longer extend from ArrayLoader but from AbstractFileLoader
# 4.1.0 / 2014-04-20
- Fixed scenario tag filtering
- Do not allow multiple multiline step arguments
- Sync 18n with cucumber
# 4.0.0 / 2014-01-05
- Changed the behavior when no loader can be found for the resource. Instead of throwing an exception, the
Gherkin class now returns an empty array.
# 3.1.3 / 2014-01-04
- Dropped the dependency on the Symfony Finder by using SPL iterators directly
- Added testing on HHVM on Travis. HHVM is officially supported (previous release was actually already compatible)
# 3.1.2 / 2014-01-01
- All paths passed to PathsFilter are converted using realpath
# 3.1.1 / 2013-12-31
- Add `ComplexFilterInterace` that has complex behavior for scenarios and requires to pass
feature too
- `TagFilter` is an instance of a `ComplexFilterInterace` now
# 3.1.0 / 2013-12-31
- Example node is a scenario
- Nodes do not have uprefs (memory usage fix)
- Scenario filters do not depend on feature nodes
# 3.0.5 / 2014-01-01
- All paths passed to PathsFilter are converted using realpath
# 3.0.4 / 2013-12-31
- TableNode is now traversable using foreach
- All possibly thrown exceptions implement Gherkin\Exception interface
- Sync i18n with cucumber
# 3.0.3 / 2013-09-15
- Extend ExampleNode with additional methods
# 3.0.2 / 2013-09-14
- Extract `KeywordNodeInterface` and `ScenarioLikeInterface`
- Add `getIndex()` methods to scenarios, outlines, steps and examples
- Throw proper exception for fractured node tree
# 3.0.1 / 2013-09-14
- Use versioned subfolder in FileCache
# 3.0.0 / 2013-09-14
- A lot of optimizations in Parser and Lexer
- Node tree is now immutable by nature (no setters)
- Example nodes are now part of the node tree. They are lazily generated by Outline node
- Sync with latest cucumber i18n
# 2.3.4 / 2013-08-11
- Fix leaks in memory cache
# 2.3.3 / 2013-08-11
- Fix encoding bug introduced with previous release
- Sync i18n with cucumber
# 2.3.2 / 2013-08-11
- Explicitly use utf8 encoding
# 2.3.1 / 2013-08-10
- Support `an` prefix with RoleFilter
# 2.3.0 / 2013-08-04
- Add RoleFilter
- Add PathsFilter
- Add MemoryCache
# 2.2.9 / 2013-03-02
- Fix dependency version requirement
# 2.2.8 / 2013-03-02
- Features filtering behavior change. Now emptified (by filtering) features
that do not match filter themselves are removed from resultset.
- Small potential bug fix in TableNode
# 2.2.7 / 2013-01-27
- Fixed bug in i18n syncing script
- Resynced Gherkin i18n
# 2.2.6 / 2013-01-26
- Support long row hashes in tables ([see](https://github.com/Behat/Gherkin/issues/40))
- Synced Gherkin i18n
# 2.2.5 / 2012-09-26
- Fixed issue with loading empty features
- Synced Gherkin i18n
# 2.2.4 / 2012-08-03
- Fixed exception message for "no loader found"
# 2.2.3 / 2012-08-03
- Fixed minor loader bug with empty base path
- Synced Gherkin i18n
# 2.2.2 / 2012-07-01
- Added ability to filter outline scenarios by line and range filters
- Synced Gherkin i18n
- Refactored table parser to read row line numbers too
# 2.2.1 / 2012-05-04
- Fixed StepNode `getLanguage()` and `getFile()`
# 2.2.0 / 2012-05-03
- Features freeze after parsing
- Implemented GherkinDumper (@Halleck45)
- Synced i18n with Cucumber
- Updated inline documentation
# 2.1.1 / 2012-03-09
- Fixed caching bug, where `isFresh()` always returned false
# 2.1.0 / 2012-03-09
- Added parser caching layer
- Added support for table delimiter escaping (use `\|` for that)
- Added LineRangeFilter (thanks @headrevision)
- Synced i18n dictionary with cucumber/gherkin
# 2.0.2 / 2012-02-04
- Synced i18n dictionary with cucumber/gherkin
# 2.0.1 / 2012-01-26
- Fixed issue about parsing features without indentation
# 2.0.0 / 2012-01-19
- Background titles support
- Correct parsing of titles/descriptions (hirarchy lexing)
- Migration to the cucumber/gherkin i18n dictionary
- Speed optimizations
- Refactored KeywordsDumper
- New loaders
- Bugfixes
# 1.1.4 / 2012-01-08
- Read feature description even if it looks like a step
# 1.1.3 / 2011-12-14
- Removed file loading routines from Parser (fixes `is_file()` issue on some systems - thanks
@flodocteurklein)
# 1.1.2 / 2011-12-01
- Updated spanish trasnaltion (@anbotero)
- Integration with Composer and Travis CI
# 1.1.1 / 2011-07-29
- Updated pt language step types (@danielcsgomes)
- Updated vendors
# 1.1.0 / 2011-07-16
- Return all tags, including inherited in `Scenario::getTags()`
- New `Feature::getOwnTags()` and `Scenario::getOwnTags()` method added,
which returns only own tags
# 1.0.8 / 2011-06-29
- Fixed comments parsing.
You can’t have comments at the end of a line # like this
# But you can still have comments at the beginning of a line
# 1.0.7 / 2011-06-28
- Added `getRaw()` method to PyStringNode
- Updated vendors
# 1.0.6 / 2011-06-17
- Updated vendors
# 1.0.5 / 2011-06-10
- Fixed bug, introduced with 1.0.4 - hash in PyStrings
# 1.0.4 / 2011-06-10
- Fixed inability to comment pystrings
# 1.0.3 / 2011-04-21
- Fixed introduced with 1.0.2 pystring parsing bug
# 1.0.2 / 2011-04-18
- Fixed bugs in text with comments parsing
# 1.0.1 / 2011-04-01
- Updated vendors
# 1.0.0 / 2011-03-08
- Updated vendors
# 1.0.0RC2 / 2011-02-25
- Windows support
- Missing phpunit config
# 1.0.0RC1 / 2011-02-15
- Huge optimizations to Lexer & Parser
- Additional loaders (Yaml, Array, Directory)
- Filters (Tag, Name, Line)
- Code refactoring
- Nodes optimizations
- Additional tests for exceptions and translations
- Keywords dumper
# 0.2.0 / 2011-01-05
- New Parser & Lexer (based on AST)
- New verbose parsing exception handling
- New translation mechanics
- 47 brand new translations (see i18n)
- Full test suite for everything from AST nodes to translations
[4.14.0]: https://github.com/Behat/Gherkin/compare/v4.13.0...v4.14.0
[4.13.0]: https://github.com/Behat/Gherkin/compare/v4.12.0...v4.13.0
[4.12.0]: https://github.com/Behat/Gherkin/compare/v4.11.0...v4.12.0
[4.11.0]: https://github.com/Behat/Gherkin/compare/v4.10.0...v4.11.0
+22
View File
@@ -0,0 +1,22 @@
Copyright (c) 2011-2013 Konstantin Kudryashov <ever.zet@gmail.com>
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
+58
View File
@@ -0,0 +1,58 @@
# Behat Gherkin Parser
This is the php Gherkin parser for Behat. It comes bundled with more than 40 native languages (see `i18n.php`) support
and clean architecture.
## Useful Links
- [Behat Site](https://behat.org)
- [Note on Patches/Pull Requests](CONTRIBUTING.md)
## Usage Example
```php
<?php
$keywords = new Behat\Gherkin\Keywords\ArrayKeywords(array(
'en' => array(
'feature' => 'Feature',
'background' => 'Background',
'scenario' => 'Scenario',
'scenario_outline' => 'Scenario Outline|Scenario Template',
'examples' => 'Examples|Scenarios',
'given' => 'Given',
'when' => 'When',
'then' => 'Then',
'and' => 'And',
'but' => 'But'
),
'en-pirate' => array(
'feature' => 'Ahoy matey!',
'background' => 'Yo-ho-ho',
'scenario' => 'Heave to',
'scenario_outline' => 'Shiver me timbers',
'examples' => 'Dead men tell no tales',
'given' => 'Gangway!',
'when' => 'Blimey!',
'then' => 'Let go and haul',
'and' => 'Aye',
'but' => 'Avast!'
)
));
$lexer = new Behat\Gherkin\Lexer($keywords);
$parser = new Behat\Gherkin\Parser($lexer);
$feature = $parser->parse(file_get_contents('some.feature'));
```
## Installing Dependencies
```shell
curl https://getcomposer.org/installer | php
php composer.phar update
```
## Contributors
* Konstantin Kudryashov [everzet](http://github.com/everzet) [lead developer]
* Other [awesome developers](https://github.com/Behat/Gherkin/graphs/contributors)
+95
View File
@@ -0,0 +1,95 @@
{
"name": "behat/gherkin",
"description": "Gherkin DSL parser for PHP",
"keywords": ["BDD", "parser", "DSL", "Behat", "Gherkin", "Cucumber"],
"homepage": "https://behat.org/",
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Konstantin Kudryashov",
"email": "ever.zet@gmail.com",
"homepage": "https://everzet.com"
}
],
"require": {
"php": "8.1.* || 8.2.* || 8.3.* || 8.4.*",
"composer-runtime-api": "^2.2"
},
"require-dev": {
"symfony/yaml": "^5.4 || ^6.4 || ^7.0",
"phpunit/phpunit": "^10.5",
"cucumber/gherkin-monorepo": "dev-gherkin-v32.1.1",
"friendsofphp/php-cs-fixer": "^3.65",
"phpstan/phpstan": "^2",
"phpstan/extension-installer": "^1",
"phpstan/phpstan-phpunit": "^2",
"mikey179/vfsstream": "^1.6"
},
"suggest": {
"symfony/yaml": "If you want to parse features, represented in YAML files"
},
"autoload": {
"psr-4": {
"Behat\\Gherkin\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\Behat\\Gherkin\\": "tests/"
}
},
"extra": {
"branch-alias": {
"dev-master": "4.x-dev"
}
},
"repositories": [
{
"type": "package",
"package": {
"name": "cucumber/gherkin-monorepo",
"version": "dev-gherkin-v32.1.1",
"source": {
"type": "git",
"url": "https://github.com/cucumber/gherkin.git",
"reference": "4fad962021cc96b4b006f711527ab07215df2d22"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/cucumber/gherkin/zipball/4fad962021cc96b4b006f711527ab07215df2d22",
"reference": "4fad962021cc96b4b006f711527ab07215df2d22"
}
}
}
],
"scripts": {
"lint": [
"phpstan analyze --ansi --no-progress --memory-limit=-1",
"phpstan analyze bin/update_cucumber --ansi --no-progress --memory-limit=-1",
"phpstan analyze bin/update_i18n --ansi --no-progress --memory-limit=-1",
"php-cs-fixer check --diff --ansi --show-progress=dots --verbose"
],
"test": [
"phpunit --colors=always"
],
"fix": [
"php-cs-fixer fix --diff --ansi --show-progress=dots"
]
},
"config": {
"process-timeout": 0,
"allow-plugins": {
"phpstan/extension-installer": true
}
}
}
+1293
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Cache;
use Behat\Gherkin\Node\FeatureNode;
/**
* Parser cache interface.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
interface CacheInterface
{
/**
* Checks that cache for feature exists and is fresh.
*
* @param string $path Feature path
* @param int $timestamp The last time feature was updated
*
* @return bool
*/
public function isFresh($path, $timestamp);
/**
* Reads feature cache from path.
*
* @param string $path Feature path
*
* @return FeatureNode
*/
public function read($path);
/**
* Caches feature node.
*
* @param string $path Feature path
* @param FeatureNode $feature Feature instance
*
* @return void
*/
public function write($path, FeatureNode $feature);
}
+122
View File
@@ -0,0 +1,122 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Cache;
use Behat\Gherkin\Exception\CacheException;
use Behat\Gherkin\Node\FeatureNode;
use Composer\InstalledVersions;
/**
* File cache.
* Caches feature into a file.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class FileCache implements CacheInterface
{
private readonly string $path;
/**
* Used as part of the cache directory path to invalidate cache if the installed package version changes.
*/
private static function getGherkinVersionHash(): string
{
$version = InstalledVersions::getVersion('behat/gherkin');
// Composer version strings can contain arbitrary content so hash for filesystem safety
return md5($version);
}
/**
* Initializes file cache.
*
* @param string $path path to the folder where to store caches
*
* @throws CacheException
*/
public function __construct(string $path)
{
$this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . self::getGherkinVersionHash();
if (!is_dir($this->path)) {
@mkdir($this->path, 0777, true);
}
if (!is_writeable($this->path)) {
throw new CacheException(sprintf('Cache path "%s" is not writeable. Check your filesystem permissions or disable Gherkin file cache.', $this->path));
}
}
/**
* Checks that cache for feature exists and is fresh.
*
* @param string $path Feature path
* @param int $timestamp The last time feature was updated
*
* @return bool
*/
public function isFresh($path, $timestamp)
{
$cachePath = $this->getCachePathFor($path);
if (!file_exists($cachePath)) {
return false;
}
return filemtime($cachePath) > $timestamp;
}
/**
* Reads feature cache from path.
*
* @param string $path Feature path
*
* @return FeatureNode
*
* @throws CacheException
*/
public function read($path)
{
$cachePath = $this->getCachePathFor($path);
$feature = unserialize(file_get_contents($cachePath));
if (!$feature instanceof FeatureNode) {
throw new CacheException(sprintf('Can not load cache for a feature "%s" from "%s".', $path, $cachePath));
}
return $feature;
}
/**
* Caches feature node.
*
* @param string $path Feature path
* @param FeatureNode $feature Feature instance
*
* @return void
*/
public function write($path, FeatureNode $feature)
{
file_put_contents($this->getCachePathFor($path), serialize($feature));
}
/**
* Returns feature cache file path from features path.
*
* @param string $path Feature path
*
* @return string
*/
protected function getCachePathFor($path)
{
return $this->path . '/' . md5($path) . '.feature.cache';
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Cache;
use Behat\Gherkin\Node\FeatureNode;
/**
* Memory cache.
* Caches feature into a memory.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class MemoryCache implements CacheInterface
{
/**
* @var array<string, FeatureNode>
*/
private array $features = [];
/**
* @var array<string, int>
*/
private array $timestamps = [];
/**
* Checks that cache for feature exists and is fresh.
*
* @param string $path Feature path
* @param int $timestamp The last time feature was updated
*
* @return bool
*/
public function isFresh($path, $timestamp)
{
if (!isset($this->features[$path])) {
return false;
}
return $this->timestamps[$path] > $timestamp;
}
/**
* Reads feature cache from path.
*
* @param string $path Feature path
*
* @return FeatureNode
*/
public function read($path)
{
return $this->features[$path];
}
/**
* Caches feature node.
*
* @param string $path Feature path
* @param FeatureNode $feature Feature instance
*
* @return void
*/
public function write($path, FeatureNode $feature)
{
$this->features[$path] = $feature;
$this->timestamps[$path] = time();
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Exception;
use RuntimeException;
/**
* Cache exception.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class CacheException extends RuntimeException implements Exception
{
}
+15
View File
@@ -0,0 +1,15 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Exception;
interface Exception
{
}
+17
View File
@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Exception;
use RuntimeException;
class LexerException extends RuntimeException implements Exception
{
}
+17
View File
@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Exception;
use RuntimeException;
class NodeException extends RuntimeException implements Exception
{
}
+17
View File
@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Exception;
use RuntimeException;
class ParserException extends RuntimeException implements Exception
{
}
@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Exception;
use Behat\Gherkin\Node\NodeInterface;
class UnexpectedParserNodeException extends ParserException
{
public function __construct(
public readonly string $expectation,
public readonly string|NodeInterface $node,
public readonly ?string $sourceFile,
) {
parent::__construct(
sprintf(
'Expected %s, but got %s%s',
$expectation,
is_string($node)
? "text: \"{$node}\""
: "{$node->getNodeType()} on line: {$node->getLine()}",
$sourceFile ? " in file: {$sourceFile}" : ''
),
);
}
}
@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Exception;
use Behat\Gherkin\Lexer;
/**
* @phpstan-import-type TToken from Lexer
*/
class UnexpectedTaggedNodeException extends ParserException
{
/**
* @phpstan-param TToken $taggedToken
*/
public function __construct(
public readonly array $taggedToken,
public readonly ?string $sourceFile,
) {
$msg = match ($this->taggedToken['type']) {
'EOS' => 'Unexpected end of file after tags',
default => sprintf(
'%s can not be tagged, but it is',
$taggedToken['type'],
),
};
parent::__construct(
sprintf(
'%s on line: %d%s',
$msg,
$taggedToken['line'],
$this->sourceFile ? " in file: {$this->sourceFile}" : '',
),
);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Filter;
use Behat\Gherkin\Node\FeatureNode;
use Behat\Gherkin\Node\ScenarioInterface;
/**
* Abstract filter class.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
abstract class ComplexFilter implements ComplexFilterInterface
{
/**
* Filters feature according to the filter.
*
* @return FeatureNode
*/
public function filterFeature(FeatureNode $feature)
{
$scenarios = $feature->getScenarios();
$filteredScenarios = array_filter(
$scenarios,
fn (ScenarioInterface $scenario) => $this->isScenarioMatch($feature, $scenario)
);
return $scenarios === $filteredScenarios ? $feature : $feature->withScenarios($filteredScenarios);
}
}
@@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Filter;
use Behat\Gherkin\Node\FeatureNode;
use Behat\Gherkin\Node\ScenarioInterface;
/**
* Filter interface.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
interface ComplexFilterInterface extends FeatureFilterInterface
{
/**
* Checks if scenario or outline matches specified filter.
*
* @param FeatureNode $feature Feature node instance
* @param ScenarioInterface $scenario Scenario or Outline node instance
*
* @return bool
*/
public function isScenarioMatch(FeatureNode $feature, ScenarioInterface $scenario);
}
@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Filter;
use Behat\Gherkin\Node\FeatureNode;
/**
* Feature filter interface.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
interface FeatureFilterInterface
{
/**
* Checks if Feature matches specified filter.
*
* @param FeatureNode $feature Feature instance
*
* @return bool
*/
public function isFeatureMatch(FeatureNode $feature);
/**
* Filters feature according to the filter and returns new one.
*
* @return FeatureNode
*/
public function filterFeature(FeatureNode $feature);
}
+30
View File
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Filter;
use Behat\Gherkin\Node\ScenarioInterface;
/**
* Filter interface.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
interface FilterInterface extends FeatureFilterInterface
{
/**
* Checks if scenario or outline matches specified filter.
*
* @param ScenarioInterface $scenario Scenario or Outline node instance
*
* @return bool
*/
public function isScenarioMatch(ScenarioInterface $scenario);
}
+110
View File
@@ -0,0 +1,110 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Filter;
use Behat\Gherkin\Node\ExampleTableNode;
use Behat\Gherkin\Node\FeatureNode;
use Behat\Gherkin\Node\OutlineNode;
use Behat\Gherkin\Node\ScenarioInterface;
/**
* Filters scenarios by definition line number.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class LineFilter implements FilterInterface
{
/**
* @var int
*/
protected $filterLine;
/**
* Initializes filter.
*
* @param int|numeric-string $filterLine Line of the scenario to filter on
*/
public function __construct($filterLine)
{
$this->filterLine = (int) $filterLine;
}
/**
* Checks if Feature matches specified filter.
*
* @param FeatureNode $feature Feature instance
*
* @return bool
*/
public function isFeatureMatch(FeatureNode $feature)
{
return $this->filterLine === $feature->getLine();
}
/**
* Checks if scenario or outline matches specified filter.
*
* @param ScenarioInterface $scenario Scenario or Outline node instance
*
* @return bool
*/
public function isScenarioMatch(ScenarioInterface $scenario)
{
if ($this->filterLine === $scenario->getLine()) {
return true;
}
if ($scenario instanceof OutlineNode && $scenario->hasExamples()) {
return $this->filterLine === $scenario->getLine()
|| in_array($this->filterLine, $scenario->getExampleTable()->getLines());
}
return false;
}
/**
* Filters feature according to the filter and returns new one.
*
* @return FeatureNode
*/
public function filterFeature(FeatureNode $feature)
{
$scenarios = [];
foreach ($feature->getScenarios() as $scenario) {
if (!$this->isScenarioMatch($scenario)) {
continue;
}
if ($scenario instanceof OutlineNode && $scenario->hasExamples()) {
foreach ($scenario->getExampleTables() as $exampleTable) {
$table = $exampleTable->getTable();
$lines = array_keys($table);
if (in_array($this->filterLine, $lines)) {
$filteredTable = [$lines[0] => $table[$lines[0]]];
if ($lines[0] !== $this->filterLine) {
$filteredTable[$this->filterLine] = $table[$this->filterLine];
}
$scenario = $scenario->withTables(
[new ExampleTableNode($filteredTable, $exampleTable->getKeyword(), $exampleTable->getTags())],
);
}
}
}
$scenarios[] = $scenario;
}
return $feature->withScenarios($scenarios);
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Filter;
use Behat\Gherkin\Node\ExampleTableNode;
use Behat\Gherkin\Node\FeatureNode;
use Behat\Gherkin\Node\OutlineNode;
use Behat\Gherkin\Node\ScenarioInterface;
/**
* Filters scenarios by definition line number range.
*
* @author Fabian Kiss <headrevision@gmail.com>
*/
class LineRangeFilter implements FilterInterface
{
/**
* @var int
*/
protected $filterMinLine;
/**
* @var int
*/
protected $filterMaxLine;
/**
* Initializes filter.
*
* @param int|numeric-string $filterMinLine Minimum line of a scenario to filter on
* @param int|numeric-string|'*' $filterMaxLine Maximum line of a scenario to filter on
*/
public function __construct($filterMinLine, $filterMaxLine)
{
$this->filterMinLine = (int) $filterMinLine;
$this->filterMaxLine = $filterMaxLine === '*' ? PHP_INT_MAX : (int) $filterMaxLine;
}
/**
* Checks if Feature matches specified filter.
*
* @param FeatureNode $feature Feature instance
*
* @return bool
*/
public function isFeatureMatch(FeatureNode $feature)
{
return $this->filterMinLine <= $feature->getLine()
&& $this->filterMaxLine >= $feature->getLine();
}
/**
* Checks if scenario or outline matches specified filter.
*
* @param ScenarioInterface $scenario Scenario or Outline node instance
*
* @return bool
*/
public function isScenarioMatch(ScenarioInterface $scenario)
{
if ($this->filterMinLine <= $scenario->getLine() && $this->filterMaxLine >= $scenario->getLine()) {
return true;
}
if ($scenario instanceof OutlineNode && $scenario->hasExamples()) {
foreach ($scenario->getExampleTable()->getLines() as $line) {
if ($this->filterMinLine <= $line && $this->filterMaxLine >= $line) {
return true;
}
}
}
return false;
}
/**
* Filters feature according to the filter.
*
* @return FeatureNode
*/
public function filterFeature(FeatureNode $feature)
{
$scenarios = [];
foreach ($feature->getScenarios() as $scenario) {
if (!$this->isScenarioMatch($scenario)) {
continue;
}
if ($scenario instanceof OutlineNode && $scenario->hasExamples()) {
// first accumulate examples and then create scenario
$exampleTableNodes = [];
foreach ($scenario->getExampleTables() as $exampleTable) {
$table = $exampleTable->getTable();
$lines = array_keys($table);
$filteredTable = [$lines[0] => $table[$lines[0]]];
unset($table[$lines[0]]);
foreach ($table as $line => $row) {
if ($this->filterMinLine <= $line && $this->filterMaxLine >= $line) {
$filteredTable[$line] = $row;
}
}
if (count($filteredTable) > 1) {
$exampleTableNodes[] = new ExampleTableNode($filteredTable, $exampleTable->getKeyword(), $exampleTable->getTags());
}
}
$scenario = $scenario->withTables($exampleTableNodes);
}
$scenarios[] = $scenario;
}
return $feature->withScenarios($scenarios);
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Filter;
use Behat\Gherkin\Node\FeatureNode;
use Behat\Gherkin\Node\ScenarioInterface;
/**
* Filters scenarios by feature/scenario name.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class NameFilter extends SimpleFilter
{
/**
* @var string
*/
protected $filterString;
/**
* Initializes filter.
*
* @param string $filterString Name filter string
*/
public function __construct($filterString)
{
$this->filterString = trim($filterString);
}
/**
* Checks if Feature matches specified filter.
*
* @param FeatureNode $feature Feature instance
*
* @return bool
*/
public function isFeatureMatch(FeatureNode $feature)
{
if ($feature->getTitle() === null) {
return false;
}
if ($this->filterString[0] === '/') {
return (bool) preg_match($this->filterString, $feature->getTitle());
}
return str_contains($feature->getTitle(), $this->filterString);
}
/**
* Checks if scenario or outline matches specified filter.
*
* @param ScenarioInterface $scenario Scenario or Outline node instance
*
* @return bool
*/
public function isScenarioMatch(ScenarioInterface $scenario)
{
if ($scenario->getTitle() === null) {
return false;
}
if ($this->filterString[0] === '/' && preg_match($this->filterString, $scenario->getTitle())) {
return true;
}
if (str_contains($scenario->getTitle(), $this->filterString)) {
return true;
}
return false;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Filter;
use Behat\Gherkin\Node\FeatureNode;
use Behat\Gherkin\Node\ScenarioInterface;
/**
* Filters features by their narrative using regular expression.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class NarrativeFilter extends SimpleFilter
{
public function __construct(
private readonly string $regex,
) {
}
public function isFeatureMatch(FeatureNode $feature)
{
return (bool) preg_match($this->regex, $feature->getDescription() ?? '');
}
public function isScenarioMatch(ScenarioInterface $scenario)
{
// This filter does not apply to scenarios.
return false;
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Filter;
use Behat\Gherkin\Node\FeatureNode;
use Behat\Gherkin\Node\ScenarioInterface;
/**
* Filters features by their paths.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class PathsFilter extends SimpleFilter
{
/**
* @var list<string>
*/
protected $filterPaths = [];
/**
* Initializes filter.
*
* @param array<array-key, string> $paths List of approved paths
*/
public function __construct(array $paths)
{
foreach ($paths as $path) {
if (($realpath = realpath($path)) === false) {
continue;
}
$this->filterPaths[] = rtrim($realpath, DIRECTORY_SEPARATOR)
. (is_dir($realpath) ? DIRECTORY_SEPARATOR : '');
}
}
public function isFeatureMatch(FeatureNode $feature)
{
foreach ($this->filterPaths as $path) {
if (str_starts_with(realpath($feature->getFile()), $path)) {
return true;
}
}
return false;
}
public function isScenarioMatch(ScenarioInterface $scenario)
{
// This filter does not apply to scenarios.
return false;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Filter;
use Behat\Gherkin\Node\FeatureNode;
use Behat\Gherkin\Node\ScenarioInterface;
/**
* Filters features by their actors role.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class RoleFilter extends SimpleFilter
{
/**
* @var string
*/
protected $pattern;
/**
* Initializes filter.
*
* @param string $role Approved role wildcard
*/
public function __construct($role)
{
$this->pattern = sprintf(
'/as an? %s[$\n]/i',
strtr(
preg_quote($role, '/'),
[
'\*' => '.*',
'\?' => '.',
'\[' => '[',
'\]' => ']',
]
)
);
}
public function isFeatureMatch(FeatureNode $feature)
{
return (bool) preg_match($this->pattern, $feature->getDescription() ?? '');
}
public function isScenarioMatch(ScenarioInterface $scenario)
{
// This filter does not apply to scenarios.
return false;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Filter;
use Behat\Gherkin\Node\FeatureNode;
/**
* Abstract filter class.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
abstract class SimpleFilter implements FilterInterface
{
/**
* Filters feature according to the filter.
*
* @return FeatureNode
*/
public function filterFeature(FeatureNode $feature)
{
if ($this->isFeatureMatch($feature)) {
return $feature;
}
return $feature->withScenarios(
array_filter(
$feature->getScenarios(),
$this->isScenarioMatch(...)
)
);
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Filter;
use Behat\Gherkin\Node\FeatureNode;
use Behat\Gherkin\Node\OutlineNode;
use Behat\Gherkin\Node\ScenarioInterface;
/**
* Filters scenarios by feature/scenario tag.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class TagFilter extends ComplexFilter
{
/**
* @var string
*/
protected $filterString;
/**
* Initializes filter.
*
* @param string $filterString Name filter string
*/
public function __construct($filterString)
{
$this->filterString = trim($filterString);
if (preg_match('/\s/u', $this->filterString)) {
trigger_error(
'Tags with whitespace are deprecated and may be removed in a future version',
E_USER_DEPRECATED
);
}
}
/**
* Filters feature according to the filter.
*
* @return FeatureNode
*/
public function filterFeature(FeatureNode $feature)
{
$scenarios = [];
foreach ($feature->getScenarios() as $scenario) {
if (!$this->isScenarioMatch($feature, $scenario)) {
continue;
}
if ($scenario instanceof OutlineNode && $scenario->hasExamples()) {
$exampleTables = [];
foreach ($scenario->getExampleTables() as $exampleTable) {
if ($this->isTagsMatchCondition(array_merge($feature->getTags(), $scenario->getTags(), $exampleTable->getTags()))) {
$exampleTables[] = $exampleTable;
}
}
$scenario = $scenario->withTables($exampleTables);
}
$scenarios[] = $scenario;
}
return $feature->withScenarios($scenarios);
}
/**
* Checks if Feature matches specified filter.
*
* @param FeatureNode $feature Feature instance
*
* @return bool
*/
public function isFeatureMatch(FeatureNode $feature)
{
return $this->isTagsMatchCondition($feature->getTags());
}
/**
* Checks if scenario or outline matches specified filter.
*
* @param FeatureNode $feature Feature node instance
* @param ScenarioInterface $scenario Scenario or Outline node instance
*
* @return bool
*/
public function isScenarioMatch(FeatureNode $feature, ScenarioInterface $scenario)
{
if ($scenario instanceof OutlineNode && $scenario->hasExamples()) {
foreach ($scenario->getExampleTables() as $example) {
if ($this->isTagsMatchCondition(array_merge($feature->getTags(), $scenario->getTags(), $example->getTags()))) {
return true;
}
}
return false;
}
return $this->isTagsMatchCondition(array_merge($feature->getTags(), $scenario->getTags()));
}
/**
* Checks that node matches condition.
*
* @param array<array-key, string> $tags
*
* @return bool
*/
protected function isTagsMatchCondition($tags)
{
if ($this->filterString === '') {
return true;
}
foreach (explode('&&', $this->filterString) as $andTags) {
$satisfiesComma = false;
foreach (explode(',', $andTags) as $tag) {
$tag = str_replace('@', '', trim($tag));
if ($tag[0] === '~') {
$tag = mb_substr($tag, 1, mb_strlen($tag, 'utf8') - 1, 'utf8');
$satisfiesComma = !in_array($tag, $tags, true) || $satisfiesComma;
} else {
$satisfiesComma = in_array($tag, $tags, true) || $satisfiesComma;
}
}
if (!$satisfiesComma) {
return false;
}
}
return true;
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin;
use Behat\Gherkin\Filter\FeatureFilterInterface;
use Behat\Gherkin\Filter\LineFilter;
use Behat\Gherkin\Filter\LineRangeFilter;
use Behat\Gherkin\Loader\FileLoaderInterface;
use Behat\Gherkin\Loader\LoaderInterface;
use Behat\Gherkin\Node\FeatureNode;
/**
* Gherkin manager.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class Gherkin
{
/**
* @deprecated this constant will not be updated for releases after 4.8.0 and will be removed in the next major.
* You can use composer's runtime API to get the behat version if you need it. Note that composer's versions will
* not always be simple numeric values.
*/
public const VERSION = '4.8.0';
/**
* @var list<LoaderInterface>
*/
protected $loaders = [];
/**
* @var list<FeatureFilterInterface>
*/
protected $filters = [];
/**
* Adds loader to manager.
*
* @param LoaderInterface $loader Feature loader
*
* @return void
*/
public function addLoader(LoaderInterface $loader)
{
$this->loaders[] = $loader;
}
/**
* Adds filter to manager.
*
* @param FeatureFilterInterface $filter Feature filter
*
* @return void
*/
public function addFilter(FeatureFilterInterface $filter)
{
$this->filters[] = $filter;
}
/**
* Sets filters to the parser.
*
* @param array<array-key, FeatureFilterInterface> $filters
*
* @return void
*/
public function setFilters(array $filters)
{
$this->filters = [];
array_map($this->addFilter(...), $filters);
}
/**
* Sets base features path.
*
* @param string $path Loaders base path
*
* @return void
*/
public function setBasePath($path)
{
foreach ($this->loaders as $loader) {
if ($loader instanceof FileLoaderInterface) {
$loader->setBasePath($path);
}
}
}
/**
* Loads & filters resource with added loaders.
*
* @param mixed $resource Resource to load
* @param array<array-key, FeatureFilterInterface> $filters Additional filters
*
* @return list<FeatureNode>
*/
public function load($resource, array $filters = [])
{
$filters = array_merge($this->filters, $filters);
$matches = [];
if (preg_match('/^(.*):(\d+)-(\d+|\*)$/', $resource, $matches)) {
$resource = $matches[1];
$filters[] = new LineRangeFilter($matches[2], $matches[3]);
} elseif (preg_match('/^(.*):(\d+)$/', $resource, $matches)) {
$resource = $matches[1];
$filters[] = new LineFilter($matches[2]);
}
$loader = $this->resolveLoader($resource);
if ($loader === null) {
return [];
}
$features = [];
foreach ($loader->load($resource) as $feature) {
foreach ($filters as $filter) {
$feature = $filter->filterFeature($feature);
if (!$feature->hasScenarios() && !$filter->isFeatureMatch($feature)) {
continue 2;
}
}
$features[] = $feature;
}
return $features;
}
/**
* Resolves loader by resource.
*
* @param mixed $resource Resource to load
*
* @return LoaderInterface|null
*/
public function resolveLoader($resource)
{
foreach ($this->loaders as $loader) {
if ($loader->supports($resource)) {
return $loader;
}
}
return null;
}
}
+206
View File
@@ -0,0 +1,206 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Keywords;
/**
* Array initializable keywords holder.
*
* $keywords = new Behat\Gherkin\Keywords\ArrayKeywords(array(
* 'en' => array(
* 'feature' => 'Feature',
* 'background' => 'Background',
* 'scenario' => 'Scenario',
* 'scenario_outline' => 'Scenario Outline|Scenario Template',
* 'examples' => 'Examples|Scenarios',
* 'given' => 'Given',
* 'when' => 'When',
* 'then' => 'Then',
* 'and' => 'And',
* 'but' => 'But'
* ),
* 'ru' => array(
* 'feature' => 'Функционал',
* 'background' => 'Предыстория',
* 'scenario' => 'Сценарий',
* 'scenario_outline' => 'Структура сценария',
* 'examples' => 'Примеры',
* 'given' => 'Допустим',
* 'when' => 'Если',
* 'then' => 'То',
* 'and' => 'И',
* 'but' => 'Но'
* )
* ));
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*
* @phpstan-import-type TMultiLanguageKeywords from KeywordsInterface
*/
class ArrayKeywords implements KeywordsInterface
{
/**
* @var array<string, string>
*/
private array $keywordString = [];
private string $language = 'en';
/**
* Initializes holder with keywords.
*
* @phpstan-param TMultiLanguageKeywords $keywords Keywords array
*/
public function __construct(
private readonly array $keywords,
) {
}
/**
* Sets keywords holder language.
*
* @param string $language Language name
*
* @return void
*/
public function setLanguage($language)
{
if (!isset($this->keywords[$language])) {
$this->language = 'en';
} else {
$this->language = $language;
}
}
/**
* Returns Feature keywords (separated by "|").
*
* @return string
*/
public function getFeatureKeywords()
{
return $this->keywords[$this->language]['feature'];
}
/**
* Returns Background keywords (separated by "|").
*
* @return string
*/
public function getBackgroundKeywords()
{
return $this->keywords[$this->language]['background'];
}
/**
* Returns Scenario keywords (separated by "|").
*
* @return string
*/
public function getScenarioKeywords()
{
return $this->keywords[$this->language]['scenario'];
}
/**
* Returns Scenario Outline keywords (separated by "|").
*
* @return string
*/
public function getOutlineKeywords()
{
return $this->keywords[$this->language]['scenario_outline'];
}
/**
* Returns Examples keywords (separated by "|").
*
* @return string
*/
public function getExamplesKeywords()
{
return $this->keywords[$this->language]['examples'];
}
/**
* Returns Given keywords (separated by "|").
*
* @return string
*/
public function getGivenKeywords()
{
return $this->keywords[$this->language]['given'];
}
/**
* Returns When keywords (separated by "|").
*
* @return string
*/
public function getWhenKeywords()
{
return $this->keywords[$this->language]['when'];
}
/**
* Returns Then keywords (separated by "|").
*
* @return string
*/
public function getThenKeywords()
{
return $this->keywords[$this->language]['then'];
}
/**
* Returns And keywords (separated by "|").
*
* @return string
*/
public function getAndKeywords()
{
return $this->keywords[$this->language]['and'];
}
/**
* Returns But keywords (separated by "|").
*
* @return string
*/
public function getButKeywords()
{
return $this->keywords[$this->language]['but'];
}
/**
* Returns all step keywords (Given, When, Then, And, But).
*
* @return string
*/
public function getStepKeywords()
{
if (!isset($this->keywordString[$this->language])) {
$keywords = array_merge(
explode('|', $this->getGivenKeywords()),
explode('|', $this->getWhenKeywords()),
explode('|', $this->getThenKeywords()),
explode('|', $this->getAndKeywords()),
explode('|', $this->getButKeywords())
);
usort($keywords, function ($keyword1, $keyword2) {
return mb_strlen($keyword2, 'utf8') - mb_strlen($keyword1, 'utf8');
});
$this->keywordString[$this->language] = implode('|', $keywords);
}
return $this->keywordString[$this->language];
}
}
@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Keywords;
/**
* File initializable keywords holder.
*
* $keywords = new Behat\Gherkin\Keywords\CachedArrayKeywords($file);
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class CachedArrayKeywords extends ArrayKeywords
{
public static function withDefaultKeywords(): self
{
return new self(__DIR__ . '/../../i18n.php');
}
/**
* Initializes holder with file.
*
* @param string $file Cached array path
*/
public function __construct($file)
{
parent::__construct(require $file);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Keywords;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
/**
* Cucumber-translations reader.
*
* $keywords = new Behat\Gherkin\Keywords\CucumberKeywords($i18nYmlPath);
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class CucumberKeywords extends ArrayKeywords
{
/**
* Initializes holder with yaml string OR file.
*
* @param string $yaml Yaml string or file path
*/
public function __construct($yaml)
{
if (!str_contains($yaml, "\n") && is_file($yaml)) {
$content = Yaml::parseFile($yaml);
} else {
$content = Yaml::parse($yaml);
}
if (!is_array($content)) {
throw new ParseException(sprintf('Root element must be an array, but %s found.', get_debug_type($content)));
}
parent::__construct($content);
}
/**
* Returns Feature keywords (separated by "|").
*
* @return string
*/
public function getGivenKeywords()
{
return $this->prepareStepString(parent::getGivenKeywords());
}
/**
* Returns When keywords (separated by "|").
*
* @return string
*/
public function getWhenKeywords()
{
return $this->prepareStepString(parent::getWhenKeywords());
}
/**
* Returns Then keywords (separated by "|").
*
* @return string
*/
public function getThenKeywords()
{
return $this->prepareStepString(parent::getThenKeywords());
}
/**
* Returns And keywords (separated by "|").
*
* @return string
*/
public function getAndKeywords()
{
return $this->prepareStepString(parent::getAndKeywords());
}
/**
* Returns But keywords (separated by "|").
*
* @return string
*/
public function getButKeywords()
{
return $this->prepareStepString(parent::getButKeywords());
}
/**
* Trim *| from the begining of the list.
*
* @param string $keywordsString Keywords string
*
* @return string
*/
private function prepareStepString($keywordsString)
{
if (str_starts_with($keywordsString, '*|')) {
$keywordsString = mb_substr($keywordsString, 2, mb_strlen($keywordsString, 'utf8') - 2, 'utf8');
}
return $keywordsString;
}
}
+373
View File
@@ -0,0 +1,373 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Keywords;
/**
* Gherkin keywords dumper.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class KeywordsDumper
{
/**
* @var callable(list<string>, bool): string
*/
private $keywordsDumper;
public function __construct(
private readonly KeywordsInterface $keywords,
) {
$this->keywordsDumper = [$this, 'dumpKeywords'];
}
/**
* Sets keywords mapper function.
*
* Callable should accept 2 arguments (array $keywords and bool $isShort)
*
* @param callable(list<string>, bool): string $mapper Mapper function
*
* @return void
*/
public function setKeywordsDumperFunction($mapper)
{
$this->keywordsDumper = $mapper;
}
/**
* Defaults keywords dumper.
*
* @param list<string> $keywords Keywords list
* @param bool $isShort Is short version
*
* @return string
*/
public function dumpKeywords(array $keywords, $isShort)
{
if ($isShort) {
return count($keywords) > 1
? '(' . implode('|', $keywords) . ')'
: $keywords[0];
}
return $keywords[0];
}
/**
* Dumps keyworded feature into string.
*
* @param string $language Keywords language
* @param bool $short Dump short version
* @param bool $excludeAsterisk
*
* @return string|array String for short version and array of features for extended
*
* @phpstan-return ($short is true ? string : list<string>)
*/
public function dump($language, $short = true, $excludeAsterisk = false)
{
$this->keywords->setLanguage($language);
$languageComment = '';
if ($language !== 'en') {
$languageComment = "# language: $language\n";
}
$keywords = explode('|', $this->keywords->getFeatureKeywords());
if ($short) {
$keywords = call_user_func($this->keywordsDumper, $keywords, $short);
return trim($languageComment . $this->dumpFeature($keywords, $short, $excludeAsterisk));
}
$features = [];
foreach ($keywords as $keyword) {
$keyword = call_user_func($this->keywordsDumper, [$keyword], $short);
$features[] = trim($languageComment . $this->dumpFeature($keyword, $short, $excludeAsterisk));
}
return $features;
}
/**
* Dumps feature example.
*
* @param string $keyword Item keyword
* @param bool $short Dump short version?
* @param bool $excludeAsterisk
*
* @return string
*/
protected function dumpFeature($keyword, $short = true, $excludeAsterisk = false)
{
$dump = <<<GHERKIN
{$keyword}: Internal operations
In order to stay secret
As a secret organization
We need to be able to erase past agents' memory
GHERKIN;
// Background
$keywords = explode('|', $this->keywords->getBackgroundKeywords());
if ($short) {
$keywords = call_user_func($this->keywordsDumper, $keywords, $short);
$dump .= $this->dumpBackground($keywords, $short, $excludeAsterisk);
} else {
$keyword = call_user_func($this->keywordsDumper, [$keywords[0]], $short);
$dump .= $this->dumpBackground($keyword, $short, $excludeAsterisk);
}
// Scenario
$keywords = explode('|', $this->keywords->getScenarioKeywords());
if ($short) {
$keywords = call_user_func($this->keywordsDumper, $keywords, $short);
$dump .= $this->dumpScenario($keywords, $short, $excludeAsterisk);
} else {
foreach ($keywords as $keyword) {
$keyword = call_user_func($this->keywordsDumper, [$keyword], $short);
$dump .= $this->dumpScenario($keyword, $short, $excludeAsterisk);
}
}
// Outline
$keywords = explode('|', $this->keywords->getOutlineKeywords());
if ($short) {
$keywords = call_user_func($this->keywordsDumper, $keywords, $short);
$dump .= $this->dumpOutline($keywords, $short, $excludeAsterisk);
} else {
foreach ($keywords as $keyword) {
$keyword = call_user_func($this->keywordsDumper, [$keyword], $short);
$dump .= $this->dumpOutline($keyword, $short, $excludeAsterisk);
}
}
return $dump;
}
/**
* Dumps background example.
*
* @param string $keyword Item keyword
* @param bool $short Dump short version?
* @param bool $excludeAsterisk
*
* @return string
*/
protected function dumpBackground($keyword, $short = true, $excludeAsterisk = false)
{
$dump = <<<GHERKIN
{$keyword}:
GHERKIN;
// Given
$dump .= $this->dumpStep(
$this->keywords->getGivenKeywords(),
'there is agent A',
$short,
$excludeAsterisk
);
// And
$dump .= $this->dumpStep(
$this->keywords->getAndKeywords(),
'there is agent B',
$short,
$excludeAsterisk
);
return $dump . "\n";
}
/**
* Dumps scenario example.
*
* @param string $keyword Item keyword
* @param bool $short Dump short version?
* @param bool $excludeAsterisk
*
* @return string
*/
protected function dumpScenario($keyword, $short = true, $excludeAsterisk = false)
{
$dump = <<<GHERKIN
{$keyword}: Erasing agent memory
GHERKIN;
// Given
$dump .= $this->dumpStep(
$this->keywords->getGivenKeywords(),
'there is agent J',
$short,
$excludeAsterisk
);
// And
$dump .= $this->dumpStep(
$this->keywords->getAndKeywords(),
'there is agent K',
$short,
$excludeAsterisk
);
// When
$dump .= $this->dumpStep(
$this->keywords->getWhenKeywords(),
'I erase agent K\'s memory',
$short,
$excludeAsterisk
);
// Then
$dump .= $this->dumpStep(
$this->keywords->getThenKeywords(),
'there should be agent J',
$short,
$excludeAsterisk
);
// But
$dump .= $this->dumpStep(
$this->keywords->getButKeywords(),
'there should not be agent K',
$short,
$excludeAsterisk
);
return $dump . "\n";
}
/**
* Dumps outline example.
*
* @param string $keyword Item keyword
* @param bool $short Dump short version?
* @param bool $excludeAsterisk
*
* @return string
*/
protected function dumpOutline($keyword, $short = true, $excludeAsterisk = false)
{
$dump = <<<GHERKIN
{$keyword}: Erasing other agents' memory
GHERKIN;
// Given
$dump .= $this->dumpStep(
$this->keywords->getGivenKeywords(),
'there is agent <agent1>',
$short,
$excludeAsterisk
);
// And
$dump .= $this->dumpStep(
$this->keywords->getAndKeywords(),
'there is agent <agent2>',
$short,
$excludeAsterisk
);
// When
$dump .= $this->dumpStep(
$this->keywords->getWhenKeywords(),
'I erase agent <agent2>\'s memory',
$short,
$excludeAsterisk
);
// Then
$dump .= $this->dumpStep(
$this->keywords->getThenKeywords(),
'there should be agent <agent1>',
$short,
$excludeAsterisk
);
// But
$dump .= $this->dumpStep(
$this->keywords->getButKeywords(),
'there should not be agent <agent2>',
$short,
$excludeAsterisk
);
$keywords = explode('|', $this->keywords->getExamplesKeywords());
if ($short) {
$keyword = call_user_func($this->keywordsDumper, $keywords, $short);
} else {
$keyword = call_user_func($this->keywordsDumper, [$keywords[0]], $short);
}
$dump .= <<<GHERKIN
{$keyword}:
| agent1 | agent2 |
| D | M |
GHERKIN;
return $dump . "\n";
}
/**
* Dumps step example.
*
* @param string $keywords Item keyword
* @param string $text Step text
* @param bool $short Dump short version?
* @param bool $excludeAsterisk
*
* @return string
*/
protected function dumpStep($keywords, $text, $short = true, $excludeAsterisk = false)
{
$dump = '';
$keywords = explode('|', $keywords);
if ($short) {
$keywords = array_map(
function ($keyword) {
return str_replace('<', '', $keyword);
},
$keywords
);
$keywords = call_user_func($this->keywordsDumper, $keywords, $short);
$dump .= <<<GHERKIN
{$keywords} {$text}
GHERKIN;
} else {
foreach ($keywords as $keyword) {
if ($excludeAsterisk && $keyword === '*') {
continue;
}
$indent = ' ';
if (str_contains($keyword, '<')) {
$keyword = mb_substr($keyword, 0, -1, 'utf8');
$indent = '';
}
$keyword = call_user_func($this->keywordsDumper, [$keyword], $short);
$dump .= <<<GHERKIN
{$keyword}{$indent}{$text}
GHERKIN;
}
}
return $dump;
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Keywords;
/**
* Keywords holder interface.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*
* @phpstan-type TLanguage string
* @phpstan-type TKeywordsArray array{feature: string, background: string, scenario: string, scenario_outline: string, examples: string, given: string, when: string, then: string, and: string, but: string}
* @phpstan-type TMultiLanguageKeywords array<TLanguage, TKeywordsArray>
*/
interface KeywordsInterface
{
/**
* Sets keywords holder language.
*
* @param string $language Language name
*
* @return void
*/
public function setLanguage($language);
/**
* Returns Feature keywords (separated by "|").
*
* @return string
*/
public function getFeatureKeywords();
/**
* Returns Background keywords (separated by "|").
*
* @return string
*/
public function getBackgroundKeywords();
/**
* Returns Scenario keywords (separated by "|").
*
* @return string
*/
public function getScenarioKeywords();
/**
* Returns Scenario Outline keywords (separated by "|").
*
* @return string
*/
public function getOutlineKeywords();
/**
* Returns Examples keywords (separated by "|").
*
* @return string
*/
public function getExamplesKeywords();
/**
* Returns Given keywords (separated by "|").
*
* @return string
*/
public function getGivenKeywords();
/**
* Returns When keywords (separated by "|").
*
* @return string
*/
public function getWhenKeywords();
/**
* Returns Then keywords (separated by "|").
*
* @return string
*/
public function getThenKeywords();
/**
* Returns And keywords (separated by "|").
*
* @return string
*/
public function getAndKeywords();
/**
* Returns But keywords (separated by "|").
*
* @return string
*/
public function getButKeywords();
/**
* Returns all step keywords (separated by "|").
*
* @return string
*/
public function getStepKeywords();
}
+767
View File
@@ -0,0 +1,767 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin;
use Behat\Gherkin\Exception\LexerException;
use Behat\Gherkin\Keywords\KeywordsInterface;
use LogicException;
use function assert;
/**
* Gherkin lexer.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*
* @phpstan-type TToken array{type: string, line: int, value: string|null, deferred: bool, tags?: list<string>, keyword?: string, keyword_type?: string, text?: string, indent?: int, columns?: list<string>}
*/
class Lexer
{
/**
* Splits a string around | char, only if it's not preceded by an odd number of \.
*
* @see https://github.com/cucumber/gherkin/blob/679a87e21263699c15ea635159c6cda60f64af3b/php/src/StringGherkinLine.php#L14
*/
private const CELL_PATTERN = '/(?<!\\\\)(?:\\\\{2})*\K\\|/u';
private string $language;
/**
* @var list<string>
*/
private array $lines;
private int $linesCount;
private string $line;
private ?string $trimmedLine = null;
private int $lineNumber;
private bool $eos;
/**
* @var array<string, string>
*/
private array $keywordsCache = [];
/**
* @var array<string, list<string>>
*/
private array $stepKeywordTypesCache = [];
/**
* @phpstan-var list<TToken>
*/
private array $deferredObjects = [];
private int $deferredObjectsCount = 0;
/**
* @phpstan-var TToken|null
*/
private ?array $stashedToken = null;
private bool $inPyString = false;
private int $pyStringSwallow = 0;
private bool $allowLanguageTag = true;
private bool $allowFeature = true;
private bool $allowMultilineArguments = false;
private bool $allowSteps = false;
private ?string $pyStringDelimiter = null;
public function __construct(
private readonly KeywordsInterface $keywords,
) {
}
/**
* Sets lexer input.
*
* @param string $input Input string
* @param string $language Language name
*
* @return void
*
* @throws LexerException
*/
public function analyse($input, $language = 'en')
{
// try to detect unsupported encoding
if (mb_detect_encoding($input, 'UTF-8', true) !== 'UTF-8') {
throw new LexerException('Feature file is not in UTF8 encoding');
}
$input = strtr($input, ["\r\n" => "\n", "\r" => "\n"]);
$this->lines = explode("\n", $input);
$this->linesCount = count($this->lines);
$this->line = $this->lines[0];
$this->lineNumber = 1;
$this->trimmedLine = null;
$this->eos = false;
$this->deferredObjects = [];
$this->deferredObjectsCount = 0;
$this->stashedToken = null;
$this->inPyString = false;
$this->pyStringSwallow = 0;
$this->allowLanguageTag = true;
$this->allowFeature = true;
$this->allowMultilineArguments = false;
$this->allowSteps = false;
$this->setLanguage($language);
}
private function setLanguage(string $language): void
{
if (($this->stashedToken !== null) || ($this->deferredObjects !== [])) {
// @codeCoverageIgnoreStart
// It is not possible to trigger this condition using the public interface of this class.
// It may be possible if the end-user has extended the Lexer with custom functionality.
throw new LogicException(
<<<'STRING'
Cannot set gherkin language due to unexpected Lexer state.
Please open an issue at https://github.com/Behat/Gherkin with a copy of the current
feature file. If you are using a Lexer or Parser class that extends the ones provided
in behat/gherkin, please also provide details of these.
STRING,
);
// @codeCoverageIgnoreEnd
}
$this->keywords->setLanguage($this->language = $language);
$this->keywordsCache = [];
$this->stepKeywordTypesCache = [];
}
/**
* Returns current lexer language.
*
* @return string
*/
public function getLanguage()
{
return $this->language;
}
/**
* Returns next token or previously stashed one.
*
* @return array
*
* @phpstan-return TToken
*/
public function getAdvancedToken()
{
return $this->getStashedToken() ?: $this->getNextToken();
}
/**
* Defers token.
*
* @phpstan-param TToken $token Token to defer
*
* @return void
*/
public function deferToken(array $token)
{
$token['deferred'] = true;
$this->deferredObjects[] = $token;
++$this->deferredObjectsCount;
}
/**
* Predicts the upcoming token without passing over it.
*
* @return array
*
* @phpstan-return TToken
*/
public function predictToken()
{
return $this->stashedToken ??= $this->getNextToken();
}
/**
* Skips over the currently-predicted token, if any.
*
* @return void
*/
public function skipPredictedToken()
{
$this->stashedToken = null;
}
/**
* Constructs token with specified parameters.
*
* @param string $type Token type
* @param string|null $value Token value
*
* @return array
*
* @phpstan-return TToken
*/
public function takeToken($type, $value = null)
{
return [
'type' => $type,
'line' => $this->lineNumber,
'value' => $value ?: null,
'deferred' => false,
];
}
/**
* Consumes line from input & increments line counter.
*
* @return void
*/
protected function consumeLine()
{
++$this->lineNumber;
if (($this->lineNumber - 1) === $this->linesCount) {
$this->eos = true;
return;
}
$this->line = $this->lines[$this->lineNumber - 1];
$this->trimmedLine = null;
}
/**
* Consumes first part of line from input without incrementing the line number.
*
* @return void
*/
protected function consumeLineUntil(int $trimmedOffset)
{
$this->line = mb_substr(ltrim($this->line), $trimmedOffset, null, 'utf-8');
$this->trimmedLine = null;
}
/**
* Returns trimmed version of line.
*
* @return string
*/
protected function getTrimmedLine()
{
return $this->trimmedLine ??= trim($this->line);
}
/**
* Returns stashed token or null if hasn't.
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function getStashedToken()
{
$stashedToken = $this->stashedToken;
$this->stashedToken = null;
return $stashedToken;
}
/**
* Returns deferred token or null if hasn't.
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function getDeferredToken()
{
if (!$this->deferredObjectsCount) {
return null;
}
--$this->deferredObjectsCount;
return array_shift($this->deferredObjects);
}
/**
* Returns next token from input.
*
* @return array
*
* @phpstan-return TToken
*/
protected function getNextToken()
{
return $this->getDeferredToken()
?: $this->scanEOS()
?: $this->scanLanguage()
?: $this->scanComment()
?: $this->scanPyStringOp()
?: $this->scanPyStringContent()
?: $this->scanStep()
?: $this->scanScenario()
?: $this->scanBackground()
?: $this->scanOutline()
?: $this->scanExamples()
?: $this->scanFeature()
?: $this->scanTags()
?: $this->scanTableRow()
?: $this->scanNewline()
?: $this->scanText();
}
/**
* Scans for token with specified regex.
*
* @param string $regex Regular expression
* @param string $type Expected token type
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function scanInput($regex, $type)
{
if (!preg_match($regex, $this->line, $matches)) {
return null;
}
$token = $this->takeToken($type, $matches[1]);
$this->consumeLine();
return $token;
}
/**
* Scans for token with specified keywords.
*
* @param string $keywords Keywords (separated by "|")
* @param string $type Expected token type
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function scanInputForKeywords($keywords, $type)
{
if (!preg_match('/^(\s*)(' . $keywords . '):\s*(.*)/u', $this->line, $matches)) {
return null;
}
$token = $this->takeToken($type, $matches[3]);
$token['keyword'] = $matches[2];
$token['indent'] = mb_strlen($matches[1], 'utf8');
$this->consumeLine();
// turn off language searching and feature detection
if ($type === 'Feature') {
$this->allowFeature = false;
$this->allowLanguageTag = false;
}
// turn off PyString and Table searching
if ($type === 'Feature' || $type === 'Scenario' || $type === 'Outline') {
$this->allowMultilineArguments = false;
} elseif ($type === 'Examples') {
$this->allowMultilineArguments = true;
}
// turn on steps searching
if ($type === 'Scenario' || $type === 'Background' || $type === 'Outline') {
$this->allowSteps = true;
}
return $token;
}
/**
* Scans EOS from input & returns it if found.
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function scanEOS()
{
if (!$this->eos) {
return null;
}
return $this->takeToken('EOS');
}
/**
* Returns keywords for provided type.
*
* @param string $type Keyword type
*
* @return string
*/
protected function getKeywords($type)
{
if (!isset($this->keywordsCache[$type])) {
$getter = 'get' . $type . 'Keywords';
$keywords = $this->keywords->$getter();
if ($type === 'Step') {
$padded = [];
foreach (explode('|', $keywords) as $keyword) {
$padded[] = str_contains($keyword, '<')
? preg_quote(mb_substr($keyword, 0, -1, 'utf8'), '/') . '\s*'
: preg_quote($keyword, '/') . '\s+';
}
$keywords = implode('|', $padded);
}
$this->keywordsCache[$type] = $keywords;
}
return $this->keywordsCache[$type];
}
/**
* Scans Feature from input & returns it if found.
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function scanFeature()
{
if (!$this->allowFeature) {
// The Feature: tag is only allowed once in a file, later in the file it may be part of a description node
return null;
}
return $this->scanInputForKeywords($this->getKeywords('Feature'), 'Feature');
}
/**
* Scans Background from input & returns it if found.
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function scanBackground()
{
return $this->scanInputForKeywords($this->getKeywords('Background'), 'Background');
}
/**
* Scans Scenario from input & returns it if found.
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function scanScenario()
{
return $this->scanInputForKeywords($this->getKeywords('Scenario'), 'Scenario');
}
/**
* Scans Scenario Outline from input & returns it if found.
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function scanOutline()
{
return $this->scanInputForKeywords($this->getKeywords('Outline'), 'Outline');
}
/**
* Scans Scenario Outline Examples from input & returns it if found.
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function scanExamples()
{
return $this->scanInputForKeywords($this->getKeywords('Examples'), 'Examples');
}
/**
* Scans Step from input & returns it if found.
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function scanStep()
{
if (!$this->allowSteps) {
return null;
}
$keywords = $this->getKeywords('Step');
if (!preg_match('/^\s*(' . $keywords . ')([^\s].*)/u', $this->line, $matches)) {
return null;
}
$keyword = trim($matches[1]);
$token = $this->takeToken('Step', $keyword);
$token['keyword_type'] = $this->getStepKeywordType($keyword);
$token['text'] = $matches[2];
$this->consumeLine();
$this->allowMultilineArguments = true;
return $token;
}
/**
* Scans PyString from input & returns it if found.
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function scanPyStringOp()
{
if (!$this->allowMultilineArguments) {
return null;
}
if (!preg_match('/^\s*(?<delimiter>"""|```)/u', $this->line, $matches, PREG_OFFSET_CAPTURE)) {
return null;
}
['delimiter' => [0 => $delimiter, 1 => $indent]] = $matches;
if ($this->inPyString) {
if ($this->pyStringDelimiter !== $delimiter) {
return null;
}
$this->pyStringDelimiter = null;
} else {
$this->pyStringDelimiter = $delimiter;
}
$this->inPyString = !$this->inPyString;
$token = $this->takeToken('PyStringOp');
$this->pyStringSwallow = $indent;
$this->consumeLine();
return $token;
}
/**
* Scans PyString content.
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function scanPyStringContent()
{
if (!$this->inPyString) {
return null;
}
$token = $this->scanText();
// swallow trailing spaces
$token['value'] = preg_replace('/^\s{0,' . $this->pyStringSwallow . '}/u', '', $token['value'] ?? '');
return $token;
}
/**
* Scans Table Row from input & returns it if found.
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function scanTableRow()
{
if (!$this->allowMultilineArguments) {
return null;
}
$line = $this->getTrimmedLine();
if (!str_starts_with($line, '|')) {
// Strictly speaking, a table row only has to begin with a pipe - content to the right
// of the final pipe will be ignored after we split the cells.
return null;
}
$rawColumns = preg_split(self::CELL_PATTERN, $line);
assert($rawColumns !== false);
// Safely remove elements before the first and last separators
array_shift($rawColumns);
array_pop($rawColumns);
$token = $this->takeToken('TableRow');
$columns = array_map(function ($column) {
return trim(str_replace(['\\|', '\\\\'], ['|', '\\'], $column));
}, $rawColumns);
$token['columns'] = $columns;
$this->consumeLine();
return $token;
}
/**
* Scans Tags from input & returns it if found.
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function scanTags()
{
$line = $this->getTrimmedLine();
if ($line === '' || !str_starts_with($line, '@')) {
return null;
}
if (preg_match('/^(?<line>.*)\s+#.*$/', $line, $matches)) {
['line' => $line] = $matches;
$this->consumeLineUntil(mb_strlen($line, 'utf-8'));
} else {
$this->consumeLine();
}
$token = $this->takeToken('Tag');
$tags = explode('@', mb_substr($line, 1, mb_strlen($line, 'utf8') - 1, 'utf8'));
$tags = array_map('trim', $tags);
$token['tags'] = $tags;
return $token;
}
/**
* Scans Language specifier from input & returns it if found.
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function scanLanguage()
{
if (!$this->allowLanguageTag) {
return null;
}
if ($this->inPyString) {
return null;
}
if (!str_starts_with(ltrim($this->line), '#')) {
return null;
}
$token = $this->scanInput('/^\s*#\s*language:\s*([\w_\-]+)\s*$/', 'Language');
if ($token) {
\assert(\is_string($token['value']));
$this->allowLanguageTag = false;
$this->setLanguage($token['value']);
}
return $token;
}
/**
* Scans Comment from input & returns it if found.
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function scanComment()
{
if ($this->inPyString) {
return null;
}
$line = $this->getTrimmedLine();
if (!str_starts_with($line, '#')) {
return null;
}
$token = $this->takeToken('Comment', $line);
$this->consumeLine();
return $token;
}
/**
* Scans Newline from input & returns it if found.
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function scanNewline()
{
if ($this->getTrimmedLine() !== '') {
return null;
}
$token = $this->takeToken('Newline');
$this->consumeLine();
return $token;
}
/**
* Scans text from input & returns it if found.
*
* @return array
*
* @phpstan-return TToken
*/
protected function scanText()
{
$token = $this->takeToken('Text', $this->line);
$this->consumeLine();
return $token;
}
/**
* Returns step type keyword (Given, When, Then, etc.).
*
* @param string $native Step keyword in provided language
*
* @return string
*/
private function getStepKeywordType($native)
{
// Consider "*" as a AND keyword so that it is normalized to the previous step type
if ($native === '*') {
return 'And';
}
if (empty($this->stepKeywordTypesCache)) {
$this->stepKeywordTypesCache = [
'Given' => explode('|', $this->keywords->getGivenKeywords()),
'When' => explode('|', $this->keywords->getWhenKeywords()),
'Then' => explode('|', $this->keywords->getThenKeywords()),
'And' => explode('|', $this->keywords->getAndKeywords()),
'But' => explode('|', $this->keywords->getButKeywords()),
];
}
foreach ($this->stepKeywordTypesCache as $type => $keywords) {
if (in_array($native, $keywords, true) || in_array($native . '<', $keywords, true)) {
return $type;
}
}
return 'Given';
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Loader;
/**
* Abstract filesystem loader.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
abstract class AbstractFileLoader implements FileLoaderInterface
{
/**
* @var string|null
*/
protected $basePath;
/**
* Sets base features path.
*
* @param string $path Base loader path
*
* @return void
*/
public function setBasePath($path)
{
$this->basePath = realpath($path);
}
/**
* Finds relative path for provided absolute (relative to base features path).
*
* @param string $path Absolute path
*
* @return string
*/
protected function findRelativePath($path)
{
if ($this->basePath !== null) {
return strtr($path, [$this->basePath . DIRECTORY_SEPARATOR => '']);
}
return $path;
}
/**
* Finds absolute path for provided relative (relative to base features path).
*
* @param string $path Relative path
*
* @return false|string
*/
protected function findAbsolutePath($path)
{
if (is_file($path) || is_dir($path)) {
return realpath($path);
}
if ($this->basePath === null) {
return false;
}
if (is_file($this->basePath . DIRECTORY_SEPARATOR . $path)
|| is_dir($this->basePath . DIRECTORY_SEPARATOR . $path)) {
return realpath($this->basePath . DIRECTORY_SEPARATOR . $path);
}
return false;
}
/**
* @throws \RuntimeException
*/
final protected function getAbsolutePath(string $path): string
{
$resolvedPath = $this->findAbsolutePath($path);
if ($resolvedPath === false) {
throw new \RuntimeException("Unable to locate absolute path of \"$path\"");
}
return $resolvedPath;
}
}
+309
View File
@@ -0,0 +1,309 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Loader;
use Behat\Gherkin\Node\BackgroundNode;
use Behat\Gherkin\Node\ExampleTableNode;
use Behat\Gherkin\Node\FeatureNode;
use Behat\Gherkin\Node\OutlineNode;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\ScenarioNode;
use Behat\Gherkin\Node\StepNode;
use Behat\Gherkin\Node\TableNode;
/**
* From-array loader.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*
* @phpstan-type TFeatureHash array{title?: string|null, description?: string|null, tags?: list<string>, keyword?: string, language?: string, line?: int, background?: TBackgroundHash|null, scenarios?: array<int, TScenarioHash|TOutlineHash>}
* @phpstan-type TBackgroundHash array{title?: string|null, keyword?: string, line?: int, steps?: array<int, TStepHash>}
* @phpstan-type TScenarioHash array{type?: 'scenario', title?: string|null, tags?: list<string>, keyword?: string, line?: int, steps?: array<int, TStepHash>}
* @phpstan-type TOutlineHash array{type: 'outline', title?: string|null, tags?: list<string>, keyword?: string, line?: int, steps?: array<int, TStepHash>, examples?: TExampleTableHash|array<array-key, TExampleHash>}
* @phpstan-type TExampleHash array{table: TExampleTableHash, tags?: list<string>}|TExampleTableHash
* @phpstan-type TExampleTableHash array<int<1, max>, list<string>>
* @phpstan-type TStepHash array{keyword_type?: string, type?: string, text: string, keyword?: string, line?: int, arguments?: array<array-key, TArgumentHash>}
* @phpstan-type TArgumentHash array{type: 'table', rows: TTableHash}|TPySstringHash
* @phpstan-type TTableHash array<int, list<string|int>>
* @phpstan-type TPySstringHash array{type: 'pystring', line?: int, text: string}
* @phpstan-type TArrayResource array{feature: TFeatureHash}|array{features: array<int, TFeatureHash>}
*/
class ArrayLoader implements LoaderInterface
{
/**
* Checks if current loader supports provided resource.
*
* @param mixed $resource Resource to load
*
* @return bool
*/
public function supports($resource)
{
return is_array($resource) && (isset($resource['features']) || isset($resource['feature']));
}
/**
* Loads features from provided resource.
*
* @param mixed $resource Resource to load
*
* @phpstan-param TArrayResource $resource
*
* @return list<FeatureNode>
*/
public function load($resource)
{
$features = [];
if (isset($resource['features'])) {
foreach ($resource['features'] as $iterator => $hash) {
$feature = $this->loadFeatureHash($hash, $iterator);
$features[] = $feature;
}
} elseif (isset($resource['feature'])) {
$feature = $this->loadFeatureHash($resource['feature']);
$features[] = $feature;
}
return $features;
}
/**
* Loads feature from provided feature hash.
*
* @phpstan-param TFeatureHash $hash
*
* @return FeatureNode
*/
protected function loadFeatureHash(array $hash, int $line = 0)
{
$hash = array_merge(
[
'title' => null,
'description' => null,
'tags' => [],
'keyword' => 'Feature',
'language' => 'en',
'line' => $line,
'scenarios' => [],
],
$hash
);
$background = isset($hash['background']) ? $this->loadBackgroundHash($hash['background']) : null;
$scenarios = [];
foreach ((array) $hash['scenarios'] as $scenarioIterator => $scenarioHash) {
if (isset($scenarioHash['type']) && $scenarioHash['type'] === 'outline') {
$scenarios[] = $this->loadOutlineHash($scenarioHash, $scenarioIterator);
} else {
$scenarios[] = $this->loadScenarioHash($scenarioHash, $scenarioIterator);
}
}
return new FeatureNode($hash['title'], $hash['description'], $hash['tags'], $background, $scenarios, $hash['keyword'], $hash['language'], null, $hash['line']);
}
/**
* Loads background from provided hash.
*
* @phpstan-param TBackgroundHash $hash
*
* @return BackgroundNode
*/
protected function loadBackgroundHash(array $hash)
{
$hash = array_merge(
[
'title' => null,
'keyword' => 'Background',
'line' => 0,
'steps' => [],
],
$hash
);
$steps = $this->loadStepsHash($hash['steps']);
return new BackgroundNode($hash['title'], $steps, $hash['keyword'], $hash['line']);
}
/**
* Loads scenario from provided scenario hash.
*
* @phpstan-param TScenarioHash $hash
*
* @return ScenarioNode
*/
protected function loadScenarioHash(array $hash, int $line = 0)
{
$hash = array_merge(
[
'title' => null,
'tags' => [],
'keyword' => 'Scenario',
'line' => $line,
'steps' => [],
],
$hash
);
$steps = $this->loadStepsHash($hash['steps']);
return new ScenarioNode($hash['title'], $hash['tags'], $steps, $hash['keyword'], $hash['line']);
}
/**
* Loads outline from provided outline hash.
*
* @phpstan-param TOutlineHash $hash
*
* @return OutlineNode
*/
protected function loadOutlineHash(array $hash, int $line = 0)
{
$hash = array_merge(
[
'title' => null,
'tags' => [],
'keyword' => 'Scenario Outline',
'line' => $line,
'steps' => [],
'examples' => [],
],
$hash
);
$steps = $this->loadStepsHash($hash['steps']);
if (isset($hash['examples']['keyword'])) {
$examplesKeyword = $hash['examples']['keyword'];
unset($hash['examples']['keyword']);
} else {
$examplesKeyword = 'Examples';
}
$examples = $this->loadExamplesHash($hash['examples'], $examplesKeyword);
return new OutlineNode($hash['title'], $hash['tags'], $steps, $examples, $hash['keyword'], $hash['line']);
}
/**
* Loads steps from provided hash.
*
* @phpstan-param array<int, TStepHash> $hash
*
* @return list<StepNode>
*/
private function loadStepsHash(array $hash)
{
$steps = [];
foreach ($hash as $stepIterator => $stepHash) {
$steps[] = $this->loadStepHash($stepHash, $stepIterator);
}
return $steps;
}
/**
* Loads step from provided hash.
*
* @phpstan-param TStepHash $hash
*
* @return StepNode
*/
protected function loadStepHash(array $hash, int $line = 0)
{
$hash = array_merge(
[
'keyword_type' => 'Given',
'type' => 'Given',
'text' => null,
'keyword' => 'Scenario',
'line' => $line,
'arguments' => [],
],
$hash
);
$arguments = [];
foreach ($hash['arguments'] as $argumentHash) {
if ($argumentHash['type'] === 'table') {
$arguments[] = $this->loadTableHash($argumentHash['rows']);
} elseif ($argumentHash['type'] === 'pystring') {
$arguments[] = $this->loadPyStringHash($argumentHash, $hash['line'] + 1);
}
}
return new StepNode($hash['type'], $hash['text'], $arguments, $hash['line'], $hash['keyword_type']);
}
/**
* Loads table from provided hash.
*
* @phpstan-param TTableHash $hash
*
* @return TableNode
*/
protected function loadTableHash(array $hash)
{
return new TableNode($hash);
}
/**
* Loads PyString from provided hash.
*
* @phpstan-param TPySstringHash $hash
*
* @return PyStringNode
*/
protected function loadPyStringHash(array $hash, int $line = 0)
{
$line = $hash['line'] ?? $line;
$strings = [];
foreach (explode("\n", $hash['text']) as $string) {
$strings[] = $string;
}
return new PyStringNode($strings, $line);
}
/**
* Processes cases when examples are in the form of array of arrays
* OR in the form of array of objects.
*
* @phpstan-param TExampleHash|array<array-key, TExampleHash> $examplesHash
*
* @return list<ExampleTableNode>
*/
private function loadExamplesHash(array $examplesHash, string $examplesKeyword): array
{
if (!isset($examplesHash[0])) {
// examples as a single table - create a list with the one element
return [new ExampleTableNode($examplesHash, $examplesKeyword)];
}
$examples = [];
foreach ($examplesHash as $exampleHash) {
if (isset($exampleHash['table'])) {
// we have examples as objects, hence there could be tags
$exHashTags = $exampleHash['tags'] ?? [];
$examples[] = new ExampleTableNode($exampleHash['table'], $examplesKeyword, $exHashTags);
} else {
// we have examples as arrays
$examples[] = new ExampleTableNode($exampleHash, $examplesKeyword);
}
}
return $examples;
}
}
@@ -0,0 +1,258 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Loader;
use Behat\Gherkin\Exception\NodeException;
use Behat\Gherkin\Node\ArgumentInterface;
use Behat\Gherkin\Node\BackgroundNode;
use Behat\Gherkin\Node\ExampleTableNode;
use Behat\Gherkin\Node\FeatureNode;
use Behat\Gherkin\Node\OutlineNode;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\ScenarioInterface;
use Behat\Gherkin\Node\ScenarioNode;
use Behat\Gherkin\Node\StepNode;
use Behat\Gherkin\Node\TableNode;
/**
* Loads a feature from cucumber's messages JSON format.
*
* Lines in the ndjson file are expected to match the Cucumber Messages JSON schema defined at https://github.com/cucumber/messages/tree/main/jsonschema
*
* @phpstan-type TLocation array{line: int, column?: int}
* @phpstan-type TBackground array{location: TLocation, keyword: string, name: string, description: string, steps: list<TStep>, id: string}
* @phpstan-type TComment array{location: TLocation, text: string}
* @phpstan-type TDataTable array{location: TLocation, rows: list<TTableRow>}
* @phpstan-type TDocString array{location: TLocation, content: string, delimiter: string, mediaType?: string}
* @phpstan-type TExamples array{location: TLocation, tags: list<TTag>, keyword: string, name: string, description: string, tableHeader?: TTableRow, tableBody: list<TTableRow>, id: string}
* @phpstan-type TFeature array{location: TLocation, tags: list<TTag>, language: string, keyword: string, name: string, description: string, children: list<TFeatureChild>}
* @phpstan-type TFeatureChild array{background?: TBackground, scenario?: TScenario, rule?: TRule}
* @phpstan-type TRule array{location: TLocation, tags: list<TTag>, keyword: string, name: string, description: string, children: list<TRuleChild>, id: string}
* @phpstan-type TRuleChild array{background?: TBackground, scenario?: TScenario}
* @phpstan-type TScenario array{location: TLocation, tags: list<TTag>, keyword: string, name: string, description: string, steps: list<TStep>, examples: list<TExamples>, id: string}
* @phpstan-type TStep array{location: TLocation, keyword: string, keywordType?: 'Unknown'|'Context'|'Action'|'Outcome'|'Conjunction', text: string, docString?: TDocString, dataTable?: TDataTable, id: string}
* @phpstan-type TTableCell array{location: TLocation, value: string}
* @phpstan-type TTableRow array{location: TLocation, cells: list<TTableCell>, id: string}
* @phpstan-type TTag array{location: TLocation, name: string, id: string}
* @phpstan-type TGherkinDocument array{uri?: string, feature?: TFeature, comments: list<TComment>}
* // We only care about the gherkinDocument messages for our use case, so this does not describe the envelope fully
* @phpstan-type TEnvelope array{gherkinDocument?: TGherkinDocument, ...}
*/
class CucumberNDJsonAstLoader implements LoaderInterface
{
public function supports($resource)
{
return is_string($resource);
}
public function load($resource)
{
return array_values(
array_filter(
array_map(
static function ($line) use ($resource) {
// As we load data from the official Cucumber project, we assume the data matches the JSON schema.
return self::getFeature(json_decode($line, true, 512, \JSON_THROW_ON_ERROR), $resource);
},
file($resource)
)
)
);
}
/**
* @phpstan-param TEnvelope $json
*/
private static function getFeature(array $json, string $filePath): ?FeatureNode
{
if (!isset($json['gherkinDocument']['feature'])) {
return null;
}
$featureJson = $json['gherkinDocument']['feature'];
return new FeatureNode(
$featureJson['name'],
$featureJson['description'],
self::getTags($featureJson),
self::getBackground($featureJson),
self::getScenarios($featureJson),
$featureJson['keyword'],
$featureJson['language'],
preg_replace('/(?<=\\.feature).*$/', '', $filePath),
$featureJson['location']['line']
);
}
/**
* @phpstan-param array{tags: list<TTag>, ...} $json
*
* @return list<string>
*/
private static function getTags(array $json): array
{
return array_map(
static fn (array $tag) => preg_replace('/^@/', '', $tag['name']),
$json['tags']
);
}
/**
* @phpstan-param TFeature $json
*
* @return list<ScenarioInterface>
*/
private static function getScenarios(array $json): array
{
return array_values(
array_map(
static function ($child) {
$tables = self::getTables($child['scenario']['examples']);
if ($tables) {
return new OutlineNode(
$child['scenario']['name'],
self::getTags($child['scenario']),
self::getSteps($child['scenario']['steps']),
$tables,
$child['scenario']['keyword'],
$child['scenario']['location']['line']
);
}
return new ScenarioNode(
$child['scenario']['name'],
self::getTags($child['scenario']),
self::getSteps($child['scenario']['steps']),
$child['scenario']['keyword'],
$child['scenario']['location']['line']
);
},
array_filter(
$json['children'],
static function ($child) {
return isset($child['scenario']);
}
)
)
);
}
/**
* @phpstan-param TFeature $json
*/
private static function getBackground(array $json): ?BackgroundNode
{
$backgrounds = array_filter(
$json['children'],
static fn ($child) => isset($child['background']),
);
if (count($backgrounds) !== 1) {
return null;
}
$background = array_shift($backgrounds);
return new BackgroundNode(
$background['background']['name'],
self::getSteps($background['background']['steps']),
$background['background']['keyword'],
$background['background']['location']['line']
);
}
/**
* @phpstan-param list<TStep> $items
*
* @return list<StepNode>
*/
private static function getSteps(array $items): array
{
return array_map(
static fn (array $item) => new StepNode(
trim($item['keyword']),
$item['text'],
self::getStepArguments($item),
$item['location']['line'],
trim($item['keyword'])
),
$items
);
}
/**
* @phpstan-param TStep $step
*
* @return list<ArgumentInterface>
*/
private static function getStepArguments(array $step): array
{
$args = [];
if (isset($step['docString'])) {
$args[] = new PyStringNode(
explode("\n", $step['docString']['content']),
$step['docString']['location']['line'],
);
}
if (isset($step['dataTable'])) {
$table = [];
foreach ($step['dataTable']['rows'] as $row) {
$table[$row['location']['line']] = array_column($row['cells'], 'value');
}
$args[] = new TableNode($table);
}
return $args;
}
/**
* @phpstan-param list<TExamples> $items
*
* @return list<ExampleTableNode>
*/
private static function getTables(array $items): array
{
return array_map(
static function ($tableJson): ExampleTableNode {
$headerRow = $tableJson['tableHeader'] ?? null;
$tableBody = $tableJson['tableBody'];
if ($headerRow === null && ($tableBody !== [])) {
throw new NodeException(
sprintf(
'Table header is required when a table body is provided for the example on line %s.',
$tableJson['location']['line'],
)
);
}
$table = [];
if ($headerRow !== null) {
$table[$headerRow['location']['line']] = array_column($headerRow['cells'], 'value');
}
foreach ($tableBody as $bodyRow) {
$table[$bodyRow['location']['line']] = array_column($bodyRow['cells'], 'value');
}
return new ExampleTableNode(
$table,
$tableJson['keyword'],
self::getTags($tableJson)
);
},
$items
);
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Loader;
use Behat\Gherkin\Gherkin;
use Behat\Gherkin\Node\FeatureNode;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
/**
* Directory contents loader.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class DirectoryLoader extends AbstractFileLoader
{
/**
* @var Gherkin
*/
protected $gherkin;
/**
* Initializes loader.
*
* @param Gherkin $gherkin Gherkin manager
*/
public function __construct(Gherkin $gherkin)
{
$this->gherkin = $gherkin;
}
/**
* Checks if current loader supports provided resource.
*
* @param mixed $resource Resource to load
*
* @return bool
*/
public function supports($resource)
{
return is_string($resource)
&& ($path = $this->findAbsolutePath($resource)) !== false
&& is_dir($path);
}
/**
* Loads features from provided resource.
*
* @param string $resource Resource to load
*
* @return list<FeatureNode>
*/
public function load($resource)
{
$path = $this->getAbsolutePath($resource);
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS)
);
$paths = array_map(strval(...), iterator_to_array($iterator));
uasort($paths, strnatcasecmp(...));
$features = [];
foreach ($paths as $path) {
$path = (string) $path;
$loader = $this->gherkin->resolveLoader($path);
if ($loader !== null) {
array_push($features, ...$loader->load($path));
}
}
return $features;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Loader;
/**
* File Loader interface.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
interface FileLoaderInterface extends LoaderInterface
{
/**
* Sets base features path.
*
* @param string $path Base loader path
*
* @return void
*/
public function setBasePath($path);
}
+108
View File
@@ -0,0 +1,108 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Loader;
use Behat\Gherkin\Cache\CacheInterface;
use Behat\Gherkin\Node\FeatureNode;
use Behat\Gherkin\Parser;
/**
* Gherkin *.feature files loader.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class GherkinFileLoader extends AbstractFileLoader
{
/**
* @var Parser
*/
protected $parser;
/**
* @var CacheInterface|null
*/
protected $cache;
/**
* Initializes loader.
*
* @param Parser $parser Parser
* @param CacheInterface|null $cache Cache layer
*/
public function __construct(Parser $parser, ?CacheInterface $cache = null)
{
$this->parser = $parser;
$this->cache = $cache;
}
/**
* Sets cache layer.
*
* @param CacheInterface $cache Cache layer
*
* @return void
*/
public function setCache(CacheInterface $cache)
{
$this->cache = $cache;
}
/**
* Checks if current loader supports provided resource.
*
* @param mixed $resource Resource to load
*
* @return bool
*/
public function supports($resource)
{
return is_string($resource)
&& ($path = $this->findAbsolutePath($resource)) !== false
&& is_file($path)
&& pathinfo($path, PATHINFO_EXTENSION) === 'feature';
}
/**
* Loads features from provided resource.
*
* @param string $resource Resource to load
*
* @return list<FeatureNode>
*/
public function load($resource)
{
$path = $this->getAbsolutePath($resource);
if ($this->cache) {
if ($this->cache->isFresh($path, filemtime($path))) {
$feature = $this->cache->read($path);
} elseif (null !== $feature = $this->parseFeature($path)) {
$this->cache->write($path, $feature);
}
} else {
$feature = $this->parseFeature($path);
}
return $feature !== null ? [$feature] : [];
}
/**
* Parses feature at provided absolute path.
*
* @param string $path Feature path
*
* @return FeatureNode|null
*/
protected function parseFeature($path)
{
$content = file_get_contents($path);
return $this->parser->parse($content, $path);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Loader;
use Behat\Gherkin\Node\FeatureNode;
/**
* Loader interface.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
interface LoaderInterface
{
/**
* Checks if current loader supports provided resource.
*
* @param mixed $resource Resource to load
*
* @return bool
*/
public function supports($resource);
/**
* Loads features from provided resource.
*
* @param mixed $resource Resource to load
*
* @return list<FeatureNode>
*/
public function load($resource);
}
+72
View File
@@ -0,0 +1,72 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Loader;
use Behat\Gherkin\Node\FeatureNode;
use Symfony\Component\Yaml\Yaml;
/**
* Yaml files loader.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class YamlFileLoader extends AbstractFileLoader
{
public function __construct(
private readonly LoaderInterface $loader = new ArrayLoader(),
) {
}
/**
* Checks if current loader supports provided resource.
*
* @param mixed $resource Resource to load
*
* @return bool
*/
public function supports($resource)
{
return is_string($resource)
&& ($path = $this->findAbsolutePath($resource)) !== false
&& is_file($path)
&& pathinfo($path, PATHINFO_EXTENSION) === 'yml';
}
/**
* Loads features from provided resource.
*
* @param string $resource Resource to load
*
* @return FeatureNode[]
*/
public function load($resource)
{
$path = $this->getAbsolutePath($resource);
$hash = Yaml::parseFile($path);
$features = $this->loader->load($hash);
return array_map(
static fn (FeatureNode $feature) => new FeatureNode(
$feature->getTitle(),
$feature->getDescription(),
$feature->getTags(),
$feature->getBackground(),
$feature->getScenarios(),
$feature->getKeyword(),
$feature->getLanguage(),
$path,
$feature->getLine()
),
$features
);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
/**
* Gherkin arguments interface.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
interface ArgumentInterface extends NodeInterface
{
}
+90
View File
@@ -0,0 +1,90 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
/**
* Represents Gherkin Background.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class BackgroundNode implements ScenarioLikeInterface
{
/**
* @param StepNode[] $steps
*/
public function __construct(
private readonly ?string $title,
private readonly array $steps,
private readonly string $keyword,
private readonly int $line,
) {
}
/**
* Returns node type string.
*
* @return string
*/
public function getNodeType()
{
return 'Background';
}
/**
* Returns background title.
*
* @return string|null
*/
public function getTitle()
{
return $this->title;
}
/**
* Checks if background has steps.
*
* @return bool
*/
public function hasSteps()
{
return (bool) count($this->steps);
}
/**
* Returns background steps.
*
* @return StepNode[]
*/
public function getSteps()
{
return $this->steps;
}
/**
* Returns background keyword.
*
* @return string
*/
public function getKeyword()
{
return $this->keyword;
}
/**
* Returns background declaration line number.
*
* @return int
*/
public function getLine()
{
return $this->line;
}
}
+243
View File
@@ -0,0 +1,243 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
/**
* Represents Gherkin Outline Example.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class ExampleNode implements ScenarioInterface, NamedScenarioInterface
{
use TaggedNodeTrait;
/**
* @var list<StepNode>|null
*/
private ?array $steps = null;
/**
* @param string $text The entire row as a string, e.g. "| 1 | 2 | 3 |"
* @param list<string> $tags
* @param array<array-key, StepNode> $outlineSteps
* @param array<string, string> $tokens
* @param int $line line number within the feature file
* @param string|null $outlineTitle original title of the scenario outline
* @param int|null $index the 1-based index of the row/example within the scenario outline
*/
public function __construct(
private readonly string $text,
private readonly array $tags,
private readonly array $outlineSteps,
private readonly array $tokens,
private readonly int $line,
private readonly ?string $outlineTitle = null,
private readonly ?int $index = null,
) {
}
/**
* Returns node type string.
*
* @return string
*/
public function getNodeType()
{
return 'Example';
}
/**
* Returns node keyword.
*
* @return string
*/
public function getKeyword()
{
return $this->getNodeType();
}
/**
* Returns the example row as a single string.
*
* @return string
*
* @deprecated you should normally not depend on the original row text, but if you really do, please switch
* to {@see self::getExampleText()} as this method will be removed in the next major version
*/
public function getTitle()
{
return $this->text;
}
public function getTags()
{
return $this->tags;
}
/**
* Checks if outline has steps.
*
* @return bool
*/
public function hasSteps()
{
return count($this->outlineSteps) > 0;
}
/**
* Returns outline steps.
*
* @return list<StepNode>
*/
public function getSteps()
{
return $this->steps ??= $this->createExampleSteps();
}
/**
* Returns example tokens.
*
* @return string[]
*/
public function getTokens()
{
return $this->tokens;
}
/**
* Returns outline declaration line number.
*
* @return int
*/
public function getLine()
{
return $this->line;
}
/**
* Returns outline title.
*
* @return string|null
*/
public function getOutlineTitle()
{
return $this->outlineTitle;
}
public function getName(): ?string
{
return "{$this->replaceTextTokens($this->outlineTitle)} #{$this->index}";
}
/**
* Returns the example row as a single string.
*
* You should normally not need this, since it is an implementation detail.
* If you need the individual example values, use {@see self::getTokens()}.
* To get the fully-normalised/expanded title, use {@see self::getName()}.
*/
public function getExampleText(): string
{
return $this->text;
}
/**
* Creates steps for this example from abstract outline steps.
*
* @return list<StepNode>
*/
protected function createExampleSteps()
{
$steps = [];
foreach ($this->outlineSteps as $outlineStep) {
$keyword = $outlineStep->getKeyword();
$keywordType = $outlineStep->getKeywordType();
$text = $this->replaceTextTokens($outlineStep->getText());
$args = $this->replaceArgumentsTokens($outlineStep->getArguments());
$line = $outlineStep->getLine();
$steps[] = new StepNode($keyword, $text, $args, $line, $keywordType);
}
return $steps;
}
/**
* Replaces tokens in arguments with row values.
*
* @param array<array-key, ArgumentInterface> $arguments
*
* @return array<array-key, ArgumentInterface>
*/
protected function replaceArgumentsTokens(array $arguments)
{
foreach ($arguments as $num => $argument) {
if ($argument instanceof TableNode) {
$arguments[$num] = $this->replaceTableArgumentTokens($argument);
}
if ($argument instanceof PyStringNode) {
$arguments[$num] = $this->replacePyStringArgumentTokens($argument);
}
}
return $arguments;
}
/**
* Replaces tokens in table with row values.
*
* @return TableNode
*/
protected function replaceTableArgumentTokens(TableNode $argument)
{
$replacedTable = [];
foreach ($argument->getTable() as $line => $row) {
$replacedRow = [];
foreach ($row as $value) {
$replacedRow[] = $this->replaceTextTokens($value);
}
$replacedTable[$line] = $replacedRow;
}
return new TableNode($replacedTable);
}
/**
* Replaces tokens in PyString with row values.
*
* @return PyStringNode
*/
protected function replacePyStringArgumentTokens(PyStringNode $argument)
{
$strings = $argument->getStrings();
foreach ($strings as $line => $string) {
$strings[$line] = $this->replaceTextTokens($strings[$line]);
}
return new PyStringNode($strings, $argument->getLine());
}
/**
* Replaces tokens in text with row values.
*
* @param string $text
*
* @return string
*/
protected function replaceTextTokens($text)
{
foreach ($this->tokens as $key => $val) {
$text = str_replace('<' . $key . '>', $val, $text);
}
return $text;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
/**
* Represents Gherkin Outline Example Table.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class ExampleTableNode extends TableNode implements TaggedNodeInterface
{
use TaggedNodeTrait;
/**
* @param array<int, list<string>> $table Table in form of [$rowLineNumber => [$val1, $val2, $val3]]
* @param list<string> $tags
*/
public function __construct(
array $table,
private readonly string $keyword,
private readonly array $tags = [],
) {
parent::__construct($table);
}
/**
* Returns node type string.
*
* @return string
*/
public function getNodeType()
{
return 'ExampleTable';
}
public function getTags()
{
return $this->tags;
}
/**
* Returns example table keyword.
*
* @return string
*/
public function getKeyword()
{
return $this->keyword;
}
}
+215
View File
@@ -0,0 +1,215 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
use InvalidArgumentException;
use function strlen;
/**
* Represents Gherkin Feature.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class FeatureNode implements KeywordNodeInterface, TaggedNodeInterface
{
use TaggedNodeTrait;
/**
* @param list<string> $tags
* @param ScenarioInterface[] $scenarios
* @param string|null $file the absolute path to the feature file
*/
public function __construct(
private readonly ?string $title,
private readonly ?string $description,
private readonly array $tags,
private readonly ?BackgroundNode $background,
private readonly array $scenarios,
private readonly string $keyword,
private readonly string $language,
private readonly ?string $file,
private readonly int $line,
) {
// Verify that the feature file is an absolute path.
if (!empty($file) && !$this->isAbsolutePath($file)) {
throw new InvalidArgumentException('The file should be an absolute path.');
}
}
/**
* Returns node type string.
*
* @return string
*/
public function getNodeType()
{
return 'Feature';
}
/**
* Returns feature title.
*
* @return string|null
*/
public function getTitle()
{
return $this->title;
}
/**
* Checks if feature has a description.
*
* @return bool
*/
public function hasDescription()
{
return !empty($this->description);
}
/**
* Returns feature description.
*
* @return string|null
*/
public function getDescription()
{
return $this->description;
}
public function getTags()
{
return $this->tags;
}
/**
* Checks if feature has background.
*
* @return bool
*/
public function hasBackground()
{
return $this->background !== null;
}
/**
* Returns feature background.
*
* @return BackgroundNode|null
*/
public function getBackground()
{
return $this->background;
}
/**
* Checks if feature has scenarios.
*
* @return bool
*/
public function hasScenarios()
{
return count($this->scenarios) > 0;
}
/**
* Returns feature scenarios.
*
* @return ScenarioInterface[]
*/
public function getScenarios()
{
return $this->scenarios;
}
/**
* Returns feature keyword.
*
* @return string
*/
public function getKeyword()
{
return $this->keyword;
}
/**
* Returns feature language.
*
* @return string
*/
public function getLanguage()
{
return $this->language;
}
/**
* Returns feature file as an absolute path.
*
* @return string|null
*/
public function getFile()
{
return $this->file;
}
/**
* Returns feature declaration line number.
*
* @return int
*/
public function getLine()
{
return $this->line;
}
/**
* Returns a copy of this feature, but with a different set of scenarios.
*
* @param array<array-key, ScenarioInterface> $scenarios
*/
public function withScenarios(array $scenarios): self
{
return new self(
$this->title,
$this->description,
$this->tags,
$this->background,
array_values($scenarios),
$this->keyword,
$this->language,
$this->file,
$this->line,
);
}
/**
* Returns whether the file path is an absolute path.
*
* @param string|null $file A file path
*
* @return bool
*
* @see https://github.com/symfony/filesystem/blob/master/Filesystem.php
*/
protected function isAbsolutePath($file)
{
if ($file === null) {
throw new InvalidArgumentException('The provided file path must not be null.');
}
return strspn($file, '/\\', 0, 1)
|| (strlen($file) > 3 && ctype_alpha($file[0])
&& $file[1] === ':'
&& strspn($file, '/\\', 2, 1)
)
|| parse_url($file, PHP_URL_SCHEME) !== null;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
/**
* Gherkin keyword node interface.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
interface KeywordNodeInterface extends NodeInterface
{
/**
* Returns node keyword.
*
* @return string
*/
public function getKeyword();
/**
* Returns node title.
*
* @return string|null
*/
public function getTitle();
}
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
interface NamedScenarioInterface
{
/**
* Returns the human-readable name of the scenario.
*/
public function getName(): ?string;
}
+33
View File
@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
/**
* Gherkin node interface.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
interface NodeInterface
{
/**
* Returns node type string.
*
* @return string
*/
public function getNodeType();
/**
* Returns feature declaration line number.
*
* @return int
*/
public function getLine();
}
+210
View File
@@ -0,0 +1,210 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
/**
* Represents Gherkin Outline.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class OutlineNode implements ScenarioInterface
{
use TaggedNodeTrait;
/**
* @var array<array-key, ExampleTableNode>
*/
private array $tables;
/**
* @var ExampleNode[]
*/
private array $examples;
/**
* @param list<string> $tags
* @param StepNode[] $steps
* @param ExampleTableNode|array<array-key, ExampleTableNode> $tables
*/
public function __construct(
private readonly ?string $title,
private readonly array $tags,
private readonly array $steps,
array|ExampleTableNode $tables,
private readonly string $keyword,
private readonly int $line,
) {
$this->tables = is_array($tables) ? $tables : [$tables];
}
/**
* Returns node type string.
*
* @return string
*/
public function getNodeType()
{
return 'Outline';
}
/**
* Returns outline title.
*
* @return string|null
*/
public function getTitle()
{
return $this->title;
}
public function getTags()
{
return $this->tags;
}
/**
* Checks if outline has steps.
*
* @return bool
*/
public function hasSteps()
{
return count($this->steps) > 0;
}
/**
* Returns outline steps.
*
* @return StepNode[]
*/
public function getSteps()
{
return $this->steps;
}
/**
* Checks if outline has examples.
*
* @return bool
*/
public function hasExamples()
{
return count($this->tables) > 0;
}
/**
* Builds and returns examples table for the outline.
*
* WARNING: it returns a merged table with tags lost.
*
* @return ExampleTableNode
*
* @deprecated use getExampleTables instead
*/
public function getExampleTable()
{
$table = [];
foreach ($this->tables[0]->getTable() as $k => $v) {
$table[$k] = $v;
}
/** @var ExampleTableNode $exampleTableNode */
$exampleTableNode = new ExampleTableNode($table, $this->tables[0]->getKeyword());
$tableCount = count($this->tables);
for ($i = 1; $i < $tableCount; ++$i) {
$exampleTableNode->mergeRowsFromTable($this->tables[$i]);
}
return $exampleTableNode;
}
/**
* Returns list of examples for the outline.
*
* @return ExampleNode[]
*/
public function getExamples()
{
return $this->examples ??= $this->createExamples();
}
/**
* Returns examples tables array for the outline.
*
* @return array<array-key, ExampleTableNode>
*/
public function getExampleTables()
{
return $this->tables;
}
/**
* Returns outline keyword.
*
* @return string
*/
public function getKeyword()
{
return $this->keyword;
}
/**
* Returns outline declaration line number.
*
* @return int
*/
public function getLine()
{
return $this->line;
}
/**
* Returns a copy of this outline, but with a different table.
*
* @param array<array-key, ExampleTableNode> $exampleTables
*/
public function withTables(array $exampleTables): self
{
return new self(
$this->title,
$this->tags,
$this->steps,
$exampleTables,
$this->keyword,
$this->line,
);
}
/**
* Creates examples for this outline using examples table.
*
* @return ExampleNode[]
*/
protected function createExamples()
{
$examples = [];
foreach ($this->getExampleTables() as $exampleTable) {
foreach ($exampleTable->getColumnsHash() as $rowNum => $row) {
$examples[] = new ExampleNode(
$exampleTable->getRowAsString($rowNum + 1),
array_merge($this->tags, $exampleTable->getTags()),
$this->getSteps(),
$row,
$exampleTable->getRowLine($rowNum + 1),
$this->getTitle(),
$rowNum + 1
);
}
}
return $examples;
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
/**
* Represents Gherkin PyString argument.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class PyStringNode implements ArgumentInterface
{
/**
* @param list<string> $strings String in form of [$stringLine]
* @param int $line Line number where string been started
*/
public function __construct(
private readonly array $strings,
private readonly int $line,
) {
}
/**
* Returns node type.
*
* @return string
*/
public function getNodeType()
{
return 'PyString';
}
/**
* Returns entire PyString lines set.
*
* @return list<string>
*/
public function getStrings()
{
return $this->strings;
}
/**
* Returns raw string.
*
* @return string
*/
public function getRaw()
{
return implode("\n", $this->strings);
}
/**
* Converts PyString into string.
*
* @return string
*/
public function __toString()
{
return $this->getRaw();
}
/**
* Returns line number at which PyString was started.
*
* @return int
*/
public function getLine()
{
return $this->line;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
/**
* Gherkin scenario interface.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
interface ScenarioInterface extends ScenarioLikeInterface, TaggedNodeInterface
{
}
+20
View File
@@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
/**
* Gherkin scenario-like interface.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
interface ScenarioLikeInterface extends KeywordNodeInterface, StepContainerInterface
{
}
+107
View File
@@ -0,0 +1,107 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
/**
* Represents Gherkin Scenario.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class ScenarioNode implements ScenarioInterface, NamedScenarioInterface
{
use TaggedNodeTrait;
/**
* @param StepNode[] $steps
* @param list<string> $tags
*/
public function __construct(
private readonly ?string $title,
private readonly array $tags,
private readonly array $steps,
private readonly string $keyword,
private readonly int $line,
) {
}
/**
* Returns node type string.
*
* @return string
*/
public function getNodeType()
{
return 'Scenario';
}
/**
* Returns scenario title.
*
* @return string|null
*
* @deprecated you should use {@see self::getName()} instead as this method will be removed in the next
* major version
*/
public function getTitle()
{
return $this->title;
}
public function getName(): ?string
{
return $this->title;
}
public function getTags()
{
return $this->tags;
}
/**
* Checks if scenario has steps.
*
* @return bool
*/
public function hasSteps()
{
return count($this->steps) > 0;
}
/**
* Returns scenario steps.
*
* @return StepNode[]
*/
public function getSteps()
{
return $this->steps;
}
/**
* Returns scenario keyword.
*
* @return string
*/
public function getKeyword()
{
return $this->keyword;
}
/**
* Returns scenario declaration line number.
*
* @return int
*/
public function getLine()
{
return $this->line;
}
}
@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
/**
* Gherkin step container interface.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
interface StepContainerInterface extends NodeInterface
{
/**
* Checks if container has steps.
*
* @return bool
*/
public function hasSteps();
/**
* Returns container steps.
*
* @return list<StepNode>
*/
public function getSteps();
}
+127
View File
@@ -0,0 +1,127 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
use Behat\Gherkin\Exception\NodeException;
/**
* Represents Gherkin Step.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
class StepNode implements NodeInterface
{
private readonly string $keywordType;
/**
* @param ArgumentInterface[] $arguments
*/
public function __construct(
private readonly string $keyword,
private readonly string $text,
private readonly array $arguments,
private readonly int $line,
?string $keywordType = null,
) {
if (count($arguments) > 1) {
throw new NodeException(sprintf(
'Steps could have only one argument, but `%s %s` have %d.',
$keyword,
$text,
count($arguments)
));
}
$this->keywordType = $keywordType ?: 'Given';
}
/**
* Returns node type string.
*
* @return string
*/
public function getNodeType()
{
return 'Step';
}
/**
* Returns step keyword in provided language (Given, When, Then, etc.).
*
* @return string
*
* @deprecated use getKeyword() instead
*/
public function getType()
{
return $this->getKeyword();
}
/**
* Returns step keyword in provided language (Given, When, Then, etc.).
*
* @return string
*/
public function getKeyword()
{
return $this->keyword;
}
/**
* Returns step type keyword (Given, When, Then, etc.).
*
* @return string
*/
public function getKeywordType()
{
return $this->keywordType;
}
/**
* Returns step text.
*
* @return string
*/
public function getText()
{
return $this->text;
}
/**
* Checks if step has arguments.
*
* @return bool
*/
public function hasArguments()
{
return (bool) count($this->arguments);
}
/**
* Returns step arguments.
*
* @return ArgumentInterface[]
*/
public function getArguments()
{
return $this->arguments;
}
/**
* Returns step declaration line number.
*
* @return int
*/
public function getLine()
{
return $this->line;
}
}
+382
View File
@@ -0,0 +1,382 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
use ArrayIterator;
use Behat\Gherkin\Exception\NodeException;
use Iterator;
use IteratorAggregate;
use ReturnTypeWillChange;
/**
* Represents Gherkin Table argument.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*
* @template-implements IteratorAggregate<int, array<string, string>>
*/
class TableNode implements ArgumentInterface, IteratorAggregate
{
/**
* @var array<array-key, int>
*/
private array $maxLineLength = [];
/**
* Initializes table.
*
* @param array<int, list<string>> $table Table in form of [$rowLineNumber => [$val1, $val2, $val3]]
*
* @throws NodeException If the given table is invalid
*/
public function __construct(
private array $table,
) {
$columnCount = null;
foreach ($this->getRows() as $ridx => $row) {
if (!is_array($row)) {
throw new NodeException(sprintf(
"Table row '%s' is expected to be array, got %s",
$ridx,
gettype($row)
));
}
if ($columnCount === null) {
$columnCount = count($row);
}
if (count($row) !== $columnCount) {
throw new NodeException(sprintf(
"Table row '%s' is expected to have %s columns, got %s",
$ridx,
$columnCount,
count($row)
));
}
foreach ($row as $column => $string) {
if (!isset($this->maxLineLength[$column])) {
$this->maxLineLength[$column] = 0;
}
if (!is_scalar($string)) {
throw new NodeException(sprintf(
"Table cell at row '%s', col '%s' is expected to be scalar, got %s",
$ridx,
$column,
gettype($string)
));
}
$this->maxLineLength[$column] = max($this->maxLineLength[$column], mb_strlen($string, 'utf8'));
}
}
}
/**
* Creates a table from a given list.
*
* @param array<int, string> $list One-dimensional array
*
* @return TableNode
*
* @throws NodeException If the given list is not a one-dimensional array
*/
public static function fromList(array $list)
{
if (count($list) !== count($list, COUNT_RECURSIVE)) {
throw new NodeException('List is not a one-dimensional array.');
}
$table = array_map(fn ($item) => [$item], $list);
return new self($table);
}
/**
* Returns node type.
*
* @return string
*/
public function getNodeType()
{
return 'Table';
}
/**
* Returns table hash, formed by columns (ColumnsHash).
*
* @return list<array<string, string>>
*/
public function getHash()
{
return $this->getColumnsHash();
}
/**
* Returns table hash, formed by columns.
*
* @return list<array<string, string>>
*/
public function getColumnsHash()
{
$rows = $this->getRows();
$keys = array_shift($rows);
$hash = [];
foreach ($rows as $row) {
\assert($keys !== null); // If there is no first row due to an empty table, we won't enter this loop either.
$hash[] = array_combine($keys, $row);
}
return $hash;
}
/**
* Returns table hash, formed by rows.
*
* @return array<string, string|list<string>>
*/
public function getRowsHash()
{
$hash = [];
foreach ($this->getRows() as $row) {
$hash[array_shift($row)] = count($row) === 1 ? $row[0] : $row;
}
return $hash;
}
/**
* Returns numerated table lines.
* Line numbers are keys, lines are values.
*
* @return array<int, list<string>>
*/
public function getTable()
{
return $this->table;
}
/**
* Returns table rows.
*
* @return list<list<string>>
*/
public function getRows()
{
return array_values($this->table);
}
/**
* Returns table definition lines.
*
* @return list<int>
*/
public function getLines()
{
return array_keys($this->table);
}
/**
* Returns specific row in a table.
*
* @param int $index Row number
*
* @return list<string>
*
* @throws NodeException If row with specified index does not exist
*/
public function getRow($index)
{
$rows = $this->getRows();
if (!isset($rows[$index])) {
throw new NodeException(sprintf('Rows #%d does not exist in table.', $index));
}
return $rows[$index];
}
/**
* Returns specific column in a table.
*
* @param int $index Column number
*
* @return list<string>
*
* @throws NodeException If column with specified index does not exist
*/
public function getColumn($index)
{
if ($index >= count($this->getRow(0))) {
throw new NodeException(sprintf('Column #%d does not exist in table.', $index));
}
$rows = $this->getRows();
$column = [];
foreach ($rows as $row) {
$column[] = $row[$index];
}
return $column;
}
/**
* Returns line number at which specific row was defined.
*
* @param int $index
*
* @return int
*
* @throws NodeException If row with specified index does not exist
*/
public function getRowLine($index)
{
$lines = array_keys($this->table);
if (!isset($lines[$index])) {
throw new NodeException(sprintf('Rows #%d does not exist in table.', $index));
}
return $lines[$index];
}
/**
* Converts row into delimited string.
*
* @param int $rowNum Row number
*
* @return string
*/
public function getRowAsString($rowNum)
{
$values = [];
foreach ($this->getRow($rowNum) as $column => $value) {
$values[] = $this->padRight(' ' . $value . ' ', $this->maxLineLength[$column] + 2);
}
return sprintf('|%s|', implode('|', $values));
}
/**
* Converts row into delimited string.
*
* @param int $rowNum Row number
* @param callable(string, int): string $wrapper Wrapper function
*
* @return string
*/
public function getRowAsStringWithWrappedValues($rowNum, $wrapper)
{
$values = [];
foreach ($this->getRow($rowNum) as $column => $value) {
$value = $this->padRight(' ' . $value . ' ', $this->maxLineLength[$column] + 2);
$values[] = call_user_func($wrapper, $value, $column);
}
return sprintf('|%s|', implode('|', $values));
}
/**
* Converts entire table into string.
*
* @return string
*/
public function getTableAsString()
{
$lines = [];
$rowCount = count($this->getRows());
for ($i = 0; $i < $rowCount; ++$i) {
$lines[] = $this->getRowAsString($i);
}
return implode("\n", $lines);
}
/**
* Returns line number at which table was started.
*
* @return int
*/
public function getLine()
{
return $this->getRowLine(0);
}
/**
* Converts table into string.
*
* @return string
*/
public function __toString()
{
return $this->getTableAsString();
}
/**
* Retrieves a hash iterator.
*
* @return Iterator
*/
#[ReturnTypeWillChange]
public function getIterator()
{
return new ArrayIterator($this->getHash());
}
/**
* Obtains and adds rows from another table to the current table.
* The second table should have the same structure as the current one.
*
* @return void
*
* @deprecated remove together with OutlineNode::getExampleTable
*/
public function mergeRowsFromTable(TableNode $node)
{
// check structure
if ($this->getRow(0) !== $node->getRow(0)) {
throw new NodeException('Tables have different structure. Cannot merge one into another');
}
$firstLine = $node->getLine();
foreach ($node->getTable() as $line => $value) {
if ($line === $firstLine) {
continue;
}
$this->table[$line] = $value;
}
}
/**
* Pads string right.
*
* @param string $text Text to pad
* @param int $length Length
*
* @return string
*/
protected function padRight($text, $length)
{
while ($length > mb_strlen($text, 'utf8')) {
$text .= ' ';
}
return $text;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
/**
* Gherkin tagged node interface.
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
interface TaggedNodeInterface extends NodeInterface
{
/**
* Checks if node is tagged with tag.
*
* @param string $tag
*
* @return bool
*/
public function hasTag($tag);
/**
* Checks if node has tags (including any inherited tags e.g. from feature).
*
* @return bool
*/
public function hasTags();
/**
* Returns node tags (including any inherited tags e.g. from feature).
*
* @return list<string>
*/
public function getTags();
}
+42
View File
@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin\Node;
/**
* This trait partially implements {@see TaggedNodeInterface}.
*
* @internal
*/
trait TaggedNodeTrait
{
/**
* @return list<string>
*/
abstract public function getTags();
/**
* @param string $tag
*
* @return bool
*/
public function hasTag($tag)
{
return in_array($tag, $this->getTags(), true);
}
/**
* @return bool
*/
public function hasTags()
{
return $this->getTags() !== [];
}
}
+690
View File
@@ -0,0 +1,690 @@
<?php
/*
* This file is part of the Behat Gherkin Parser.
* (c) Konstantin Kudryashov <ever.zet@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Behat\Gherkin;
use Behat\Gherkin\Exception\LexerException;
use Behat\Gherkin\Exception\NodeException;
use Behat\Gherkin\Exception\ParserException;
use Behat\Gherkin\Exception\UnexpectedParserNodeException;
use Behat\Gherkin\Exception\UnexpectedTaggedNodeException;
use Behat\Gherkin\Node\BackgroundNode;
use Behat\Gherkin\Node\ExampleTableNode;
use Behat\Gherkin\Node\FeatureNode;
use Behat\Gherkin\Node\OutlineNode;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\ScenarioInterface;
use Behat\Gherkin\Node\ScenarioNode;
use Behat\Gherkin\Node\StepNode;
use Behat\Gherkin\Node\TableNode;
/**
* Gherkin parser.
*
* $lexer = new Behat\Gherkin\Lexer($keywords);
* $parser = new Behat\Gherkin\Parser($lexer);
* $featuresArray = $parser->parse('/path/to/feature.feature');
*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*
* @phpstan-import-type TToken from Lexer
*
* @phpstan-type TParsedExpressionResult FeatureNode|BackgroundNode|ScenarioNode|OutlineNode|ExampleTableNode|TableNode|PyStringNode|StepNode|string
*/
class Parser
{
private string $input;
private ?string $file = null;
/**
* @var list<string>
*/
private array $tags = [];
public function __construct(
private readonly Lexer $lexer,
) {
}
/**
* Parses input & returns features array.
*
* @param string $input Gherkin string document
* @param string|null $file File name
*
* @return FeatureNode|null
*
* @throws ParserException
*/
public function parse($input, $file = null)
{
$this->input = $input;
$this->file = $file;
$this->tags = [];
try {
$this->lexer->analyse($this->input, 'en');
} catch (LexerException $e) {
throw new ParserException(
sprintf('Lexer exception "%s" thrown for file %s', $e->getMessage(), $file),
0,
$e
);
}
$feature = null;
while ($this->predictTokenType() !== 'EOS') {
$node = $this->parseExpression();
if ($node === "\n" || $node === '') {
continue;
}
if (!$feature && $node instanceof FeatureNode) {
$feature = $node;
continue;
}
throw new UnexpectedParserNodeException('Feature', $node, $this->file);
}
return $feature;
}
/**
* Returns next token if it's type equals to expected.
*
* @param string $type Token type
*
* @return array
*
* @phpstan-return TToken
*
* @throws ParserException
*/
protected function expectTokenType($type)
{
$types = (array) $type;
if (in_array($this->predictTokenType(), $types)) {
return $this->lexer->getAdvancedToken();
}
$token = $this->lexer->predictToken();
throw new ParserException(sprintf(
'Expected %s token, but got %s on line: %d%s',
implode(' or ', $types),
$this->predictTokenType(),
$token['line'],
$this->file ? ' in file: ' . $this->file : ''
));
}
/**
* Returns next token if it's type equals to expected.
*
* @param string $type Token type
*
* @return array|null
*
* @phpstan-return TToken|null
*/
protected function acceptTokenType($type)
{
if ($type !== $this->predictTokenType()) {
return null;
}
return $this->lexer->getAdvancedToken();
}
/**
* Returns next token type without real input reading (prediction).
*
* @return string
*/
protected function predictTokenType()
{
$token = $this->lexer->predictToken();
return $token['type'];
}
/**
* Parses current expression & returns Node.
*
* @phpstan-return TParsedExpressionResult
*
* @throws ParserException
*/
protected function parseExpression()
{
$type = $this->predictTokenType();
while ($type === 'Comment') {
$this->expectTokenType('Comment');
$type = $this->predictTokenType();
}
return match ($type) {
'Feature' => $this->parseFeature(),
'Background' => $this->parseBackground(),
'Scenario' => $this->parseScenario(),
'Outline' => $this->parseOutline(),
'Examples' => $this->parseExamples(),
'TableRow' => $this->parseTable(),
'PyStringOp' => $this->parsePyString(),
'Step' => $this->parseStep(),
'Text' => $this->parseText(),
'Newline' => $this->parseNewline(),
'Tag' => $this->parseTags(),
'Language' => $this->parseLanguage(),
'EOS' => '',
default => throw new ParserException(sprintf('Unknown token type: %s', $type)),
};
}
/**
* Parses feature token & returns it's node.
*
* @return FeatureNode
*
* @throws ParserException
*/
protected function parseFeature()
{
$token = $this->expectTokenType('Feature');
\assert(\array_key_exists('keyword', $token));
\assert(\array_key_exists('indent', $token));
$title = trim($token['value'] ?? '');
$description = null;
$tags = $this->popTags();
$background = null;
$scenarios = [];
$keyword = $token['keyword'];
$language = $this->lexer->getLanguage();
$file = $this->file;
$line = $token['line'];
// Parse description, background, scenarios & outlines
while ($this->predictTokenType() !== 'EOS') {
$node = $this->parseExpression();
if (is_string($node)) {
$text = preg_replace('/^\s{0,' . ($token['indent'] + 2) . '}|\s*$/', '', $node);
$description .= ($description !== null ? "\n" : '') . $text;
continue;
}
$isBackgroundAllowed = ($background === null && $scenarios === []);
if ($isBackgroundAllowed && $node instanceof BackgroundNode) {
$background = $node;
continue;
}
if ($node instanceof ScenarioInterface) {
$scenarios[] = $node;
continue;
}
throw new UnexpectedParserNodeException(
match ($isBackgroundAllowed) {
true => 'Background, Scenario or Outline',
false => 'Scenario or Outline',
},
$node,
$this->file,
);
}
return new FeatureNode(
rtrim($title) ?: null,
rtrim($description ?? '') ?: null,
$tags,
$background,
$scenarios,
$keyword,
$language,
$file,
$line
);
}
/**
* Parses background token & returns it's node.
*
* @return BackgroundNode
*
* @throws ParserException
*/
protected function parseBackground()
{
$token = $this->expectTokenType('Background');
\assert(\array_key_exists('keyword', $token));
\assert(\array_key_exists('indent', $token));
$title = trim($token['value'] ?? '');
$keyword = $token['keyword'];
$line = $token['line'];
if (count($this->popTags()) !== 0) {
// Should not be possible to happen, parseTags should have already picked this up.
throw new UnexpectedTaggedNodeException($token, $this->file);
}
// Parse description and steps
$steps = [];
$allowedTokenTypes = ['Step', 'Newline', 'Text', 'Comment'];
while (in_array($this->predictTokenType(), $allowedTokenTypes)) {
$node = $this->parseExpression();
if ($node instanceof StepNode) {
$steps[] = $this->normalizeStepNodeKeywordType($node, $steps);
continue;
}
if ($steps === [] && is_string($node)) {
$text = preg_replace('/^\s{0,' . ($token['indent'] + 2) . '}|\s*$/', '', $node);
$title .= "\n" . $text;
continue;
}
if ($node === "\n") {
continue;
}
throw new UnexpectedParserNodeException('Step', $node, $this->file);
}
return new BackgroundNode(rtrim($title) ?: null, $steps, $keyword, $line);
}
/**
* Parses scenario token & returns it's node.
*
* @return OutlineNode|ScenarioNode
*
* @throws ParserException
*/
protected function parseScenario()
{
return $this->parseScenarioOrOutlineBody($this->expectTokenType('Scenario'));
}
/**
* Parses scenario outline token & returns it's node.
*
* @return OutlineNode|ScenarioNode
*
* @throws ParserException
*/
protected function parseOutline()
{
return $this->parseScenarioOrOutlineBody($this->expectTokenType('Outline'));
}
/**
* @phpstan-param TToken $token
*/
private function parseScenarioOrOutlineBody(array $token): OutlineNode|ScenarioNode
{
\assert(\array_key_exists('keyword', $token));
\assert(\array_key_exists('indent', $token));
$title = trim($token['value'] ?? '');
$tags = $this->popTags();
$keyword = $token['keyword'];
/** @var list<ExampleTableNode> $examples */
$examples = [];
$line = $token['line'];
// Parse description, steps and examples
$steps = [];
while (in_array($nextTokenType = $this->predictTokenType(), ['Step', 'Examples', 'Newline', 'Text', 'Comment', 'Tag'])) {
if ($nextTokenType === 'Comment') {
$this->lexer->skipPredictedToken();
continue;
}
if ($nextTokenType === 'Tag') {
// The only thing inside a Scenario / Scenario Outline that can be tagged is an Examples table
// Scan on to see what the tags are attached to - if it's not Examples then we must have reached the
// end of this scenario and be about to start a new one.
if ($this->validateAndGetNextTaggedNodeType() !== 'Examples') {
break;
}
}
$node = $this->parseExpression();
if ($steps === [] && is_string($node)) {
// Free text is only allowed before the first step (or when parsing Examples: which is done elsewhere)
$text = preg_replace('/^\s{0,' . ($token['indent'] + 2) . '}|\s*$/', '', $node);
$title .= "\n" . $text;
continue;
}
if ($node === "\n") {
continue;
}
if ($examples === [] && $node instanceof StepNode) {
// Steps are only allowed before the first Examples table (if any)
$steps[] = $this->normalizeStepNodeKeywordType($node, $steps);
continue;
}
if ($node instanceof ExampleTableNode) {
// NB: It is valid to have a Scenario with Examples: but no Steps
// It is also valid to have an Examples: with no table rows (this produces no actual examples)
$examples[] = $node;
continue;
}
throw new UnexpectedParserNodeException(
match ($examples) {
[] => 'Step, Examples table, or end of Scenario',
default => 'Examples table or end of Scenario',
},
$node,
$this->file,
);
}
if ($examples !== []) {
return new OutlineNode(rtrim($title) ?: null, $tags, $steps, $examples, $keyword, $line);
}
return new ScenarioNode(rtrim($title) ?: null, $tags, $steps, $keyword, $line);
}
/**
* Peek ahead to find the node that the current tags belong to.
*
* @throws UnexpectedTaggedNodeException if there is not a taggable node
*/
private function validateAndGetNextTaggedNodeType(): string
{
$deferred = [];
try {
while (true) {
$deferred[] = $next = $this->lexer->getAdvancedToken();
$nextType = $next['type'];
if (in_array($nextType, ['Tag', 'Comment', 'Newline'], true)) {
// These are the only node types allowed between tag node(s) and the node they are tagging
continue;
}
if (in_array($nextType, ['Feature', 'Examples', 'Scenario', 'Outline'], true)) {
// These are the only taggable node types
return $nextType;
}
throw new UnexpectedTaggedNodeException($next, $this->file);
}
} finally {
// Rewind the lexer back to where it was when we started scanning ahead
foreach ($deferred as $token) {
$this->lexer->deferToken($token);
}
}
}
/**
* Parses step token & returns it's node.
*
* @return StepNode
*/
protected function parseStep()
{
$token = $this->expectTokenType('Step');
\assert(\is_string($token['value']));
\assert(\array_key_exists('keyword_type', $token));
\assert(\array_key_exists('text', $token));
$keyword = $token['value'];
$keywordType = $token['keyword_type'];
$text = trim($token['text']);
$line = $token['line'];
$arguments = [];
while (in_array($predicted = $this->predictTokenType(), ['PyStringOp', 'TableRow', 'Newline', 'Comment'])) {
if ($predicted === 'Comment' || $predicted === 'Newline') {
$this->acceptTokenType($predicted);
continue;
}
$node = $this->parseExpression();
if ($node instanceof PyStringNode || $node instanceof TableNode) {
$arguments[] = $node;
}
}
return new StepNode($keyword, $text, $arguments, $line, $keywordType);
}
/**
* Parses examples table node.
*
* @return ExampleTableNode
*/
protected function parseExamples()
{
$token = $this->expectTokenType('Examples');
\assert(\array_key_exists('keyword', $token));
$keyword = $token['keyword'];
$tags = empty($this->tags) ? [] : $this->popTags();
$table = $this->parseTableRows();
try {
return new ExampleTableNode($table, $keyword, $tags);
} catch (NodeException $e) {
$this->rethrowNodeException($e);
}
}
/**
* Parses table token & returns it's node.
*
* @return TableNode
*/
protected function parseTable()
{
$table = $this->parseTableRows();
try {
return new TableNode($table);
} catch (NodeException $e) {
$this->rethrowNodeException($e);
}
}
/**
* Parses PyString token & returns it's node.
*
* @return PyStringNode
*/
protected function parsePyString()
{
$token = $this->expectTokenType('PyStringOp');
$line = $token['line'];
$strings = [];
while ('PyStringOp' !== ($predicted = $this->predictTokenType()) && $predicted === 'Text') {
$token = $this->expectTokenType('Text');
\assert(\is_string($token['value']));
$strings[] = $token['value'];
}
$this->expectTokenType('PyStringOp');
return new PyStringNode($strings, $line);
}
/**
* Parses tags.
*
* @phpstan-return TParsedExpressionResult
*/
protected function parseTags()
{
$token = $this->expectTokenType('Tag');
\assert(\array_key_exists('tags', $token));
// Validate that the tags are followed by a node that can be tagged
$this->validateAndGetNextTaggedNodeType();
$this->guardTags($token['tags']);
$this->tags = array_merge($this->tags, $token['tags']);
return "\n";
}
/**
* Returns current set of tags and clears tag buffer.
*
* @return list<string>
*/
protected function popTags()
{
$tags = $this->tags;
$this->tags = [];
return $tags;
}
/**
* Checks the tags fit the required format.
*
* @param string[] $tags
*
* @return void
*/
protected function guardTags(array $tags)
{
foreach ($tags as $tag) {
if (preg_match('/\s/', $tag)) {
trigger_error(
sprintf('Whitespace in tags is deprecated, found "%s"', $tag),
E_USER_DEPRECATED
);
}
}
}
/**
* Parses next text line & returns it.
*
* @return string
*/
protected function parseText()
{
$token = $this->expectTokenType('Text');
\assert(\is_string($token['value']));
return $token['value'];
}
/**
* Parses next newline & returns \n.
*
* @return string
*/
protected function parseNewline()
{
$this->expectTokenType('Newline');
return "\n";
}
/**
* Skips over language tags (they are handled inside the Lexer).
*
* @phpstan-return TParsedExpressionResult
*
* @throws ParserException
*
* @deprecated language tags are handled inside the Lexer, they skipped over (like any other comment) in the Parser
*/
protected function parseLanguage()
{
$this->expectTokenType('Language');
return $this->parseExpression();
}
/**
* Parses the rows of a table.
*
* @return array<int, list<string>>
*/
private function parseTableRows()
{
$table = [];
while (in_array($predicted = $this->predictTokenType(), ['TableRow', 'Newline', 'Comment'])) {
if ($predicted === 'Comment' || $predicted === 'Newline') {
$this->acceptTokenType($predicted);
continue;
}
$token = $this->expectTokenType('TableRow');
\assert(\array_key_exists('columns', $token));
$table[$token['line']] = $token['columns'];
}
return $table;
}
/**
* Changes step node type for types But, And to type of previous step if it exists else sets to Given.
*
* @param StepNode[] $steps
*
* @return StepNode
*/
private function normalizeStepNodeKeywordType(StepNode $node, array $steps = [])
{
if (!in_array($node->getKeywordType(), ['And', 'But'])) {
return $node;
}
if ($prev = end($steps)) {
$keywordType = $prev->getKeywordType();
} else {
$keywordType = 'Given';
}
return new StepNode(
$node->getKeyword(),
$node->getText(),
$node->getArguments(),
$node->getLine(),
$keywordType
);
}
private function rethrowNodeException(NodeException $e): never
{
throw new ParserException(
$e->getMessage() . ($this->file ? ' in file ' . $this->file : ''),
0,
$e
);
}
}
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../codeception/codeception/codecept)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/codeception/codeception/codecept');
}
}
return include __DIR__ . '/..'.'/codeception/codeception/codecept';
+5
View File
@@ -0,0 +1,5 @@
@ECHO OFF
setlocal DISABLEDELAYEDEXPANSION
SET BIN_TARGET=%~dp0/codecept
SET COMPOSER_RUNTIME_BIN_DIR=%~dp0
php "%BIN_TARGET%" %*
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../cebe/markdown/bin/markdown)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/cebe/markdown/bin/markdown');
}
}
return include __DIR__ . '/..'.'/cebe/markdown/bin/markdown';
+5
View File
@@ -0,0 +1,5 @@
@ECHO OFF
setlocal DISABLEDELAYEDEXPANSION
SET BIN_TARGET=%~dp0/markdown
SET COMPOSER_RUNTIME_BIN_DIR=%~dp0
php "%BIN_TARGET%" %*
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../nikic/php-parser/bin/php-parse)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/nikic/php-parser/bin/php-parse');
}
}
return include __DIR__ . '/..'.'/nikic/php-parser/bin/php-parse';
+5
View File
@@ -0,0 +1,5 @@
@ECHO OFF
setlocal DISABLEDELAYEDEXPANSION
SET BIN_TARGET=%~dp0/php-parse
SET COMPOSER_RUNTIME_BIN_DIR=%~dp0
php "%BIN_TARGET%" %*
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../phpunit/phpunit/phpunit)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
$GLOBALS['__PHPUNIT_ISOLATION_EXCLUDE_LIST'] = $GLOBALS['__PHPUNIT_ISOLATION_BLACKLIST'] = array(realpath(__DIR__ . '/..'.'/phpunit/phpunit/phpunit'));
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = 'phpvfscomposer://'.$this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$data = str_replace('__DIR__', var_export(dirname($this->realpath), true), $data);
$data = str_replace('__FILE__', var_export($this->realpath, true), $data);
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/phpunit/phpunit/phpunit');
}
}
return include __DIR__ . '/..'.'/phpunit/phpunit/phpunit';
+5
View File
@@ -0,0 +1,5 @@
@ECHO OFF
setlocal DISABLEDELAYEDEXPANSION
SET BIN_TARGET=%~dp0/phpunit
SET COMPOSER_RUNTIME_BIN_DIR=%~dp0
php "%BIN_TARGET%" %*
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../psy/psysh/bin/psysh)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/psy/psysh/bin/psysh');
}
}
return include __DIR__ . '/..'.'/psy/psysh/bin/psysh';
+5
View File
@@ -0,0 +1,5 @@
@ECHO OFF
setlocal DISABLEDELAYEDEXPANSION
SET BIN_TARGET=%~dp0/psysh
SET COMPOSER_RUNTIME_BIN_DIR=%~dp0
php "%BIN_TARGET%" %*
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../symfony/var-dumper/Resources/bin/var-dump-server)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/symfony/var-dumper/Resources/bin/var-dump-server');
}
}
return include __DIR__ . '/..'.'/symfony/var-dumper/Resources/bin/var-dump-server';

Some files were not shown because too many files have changed in this diff Show More