diff --git a/.docker/app_test/Dockerfile b/.docker/app_test/Dockerfile
new file mode 100644
index 0000000..134b5fb
--- /dev/null
+++ b/.docker/app_test/Dockerfile
@@ -0,0 +1,17 @@
+FROM php:7.4-cli
+
+RUN apt-get update && apt-get install -y git unzip
+
+ENV COMPOSER_ALLOW_SUPERUSER 1
+ENV COMPOSER_MEMORY_LIMIT -1
+
+RUN mkdir /.composer_cache
+ENV COMPOSER_CACHE_DIR /.composer_cache
+
+RUN mkdir /packages
+COPY packages /packages/
+WORKDIR /packages/Saga
+
+RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
+RUN composer -vvv global require hirak/prestissimo
+RUN composer install
diff --git a/.docker/php7.4-dev/Dockerfile b/.docker/php7.4-dev/Dockerfile
new file mode 100644
index 0000000..72c38a7
--- /dev/null
+++ b/.docker/php7.4-dev/Dockerfile
@@ -0,0 +1,17 @@
+FROM php:7.4-cli
+
+RUN apt-get update && apt-get install -y git unzip
+
+ENV COMPOSER_ALLOW_SUPERUSER 1
+ENV COMPOSER_MEMORY_LIMIT -1
+
+RUN mkdir /.composer_cache
+ENV COMPOSER_CACHE_DIR /.composer_cache
+
+RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
+
+RUN composer -vvv global require hirak/prestissimo
+
+# php extensions
+RUN pecl install xdebug
+RUN docker-php-ext-enable xdebug
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..f5939b2
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,25 @@
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+end_of_line = lf
+insert_final_newline = true
+charset = utf-8
+
+# 4 space indentation
+[*.php]
+indent_style = space
+indent_size = 4
+
+# Tab indentation (no size specified)
+[Makefile]
+indent_style = tab
+
+# Matches the exact files either package.json or .travis.yml
+[{*.yml, *.yaml}]
+indent_style = space
+indent_size = 2
+
+[composer.json]
+indent_size = 4
diff --git a/.env b/.env
new file mode 100644
index 0000000..481280d
--- /dev/null
+++ b/.env
@@ -0,0 +1,4 @@
+###> common variables ###
+GENERATOR_COMPOSE_PROJECT_NAME=unit-test-generator
+CI_COMMIT_REF_SLUG=master
+###< common variables ###
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..372f8fa
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,38 @@
+###> symfony/framework-bundle ###
+.env.local
+.env.local.php
+.env.*.local
+.env.dist
+
+var/
+vendor/
+###< symfony/framework-bundle ###
+
+###> PhpStorm project profile ###
+.idea/
+###< PhpStorm project profile ###
+
+###> phpunit/phpunit ###
+phpunit.xml
+.phpunit.result.cache
+###< phpunit/phpunit ###
+
+###> friendsofphp/php-cs-fixer ###
+.php_cs.cache
+###< friendsofphp/php-cs-fixer ###
+
+###> squizlabs/php_codesniffer ###
+.phpcs-cache
+phpcs.xml
+###< squizlabs/php_codesniffer ###
+
+###> sensiolabs-de/deptrac ###
+.deptrac.cache
+###< sensiolabs-de/deptrac ###
+
+# Build data
+build/
+
+###> Phpunit ###
+bin/.phpunit
+###< Phpunit ###
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..94d87df
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,79 @@
+version = $(shell git describe --tags --dirty --always)
+build_name = application-$(version)
+# use the rest as arguments for "run"
+RUN_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
+# ...and turn them into do-nothing targets
+#$(eval $(RUN_ARGS):;@:)
+
+.PHONY: fix-permission
+fix-permission: ## fix permission for docker env
+ sudo chown -R $(shell whoami):$(shell whoami) *
+ sudo chown -R $(shell whoami):$(shell whoami) .docker/*
+
+.PHONY: build
+build: ## build environment and initialize composer and project dependencies
+ docker-compose build
+ make composer-install
+
+.PHONY: composer-install
+composer-install: ## Install project dependencies
+ docker-compose run --rm --no-deps php sh -lc 'composer install'
+
+.PHONY: composer-update
+composer-update: ## Update project dependencies
+ docker-compose run --rm --no-deps php sh -lc 'composer update'
+
+.PHONY: composer-outdated
+composer-outdated: ## Show outdated project dependencies
+ docker-compose run --rm --no-deps php sh -lc 'composer outdated'
+
+.PHONY: composer-validate
+composer-validate: ## Validate composer config
+ docker-compose run --rm --no-deps php sh -lc 'composer validate --no-check-publish'
+
+.PHONY: composer
+composer: ## Execute composer command
+ docker-compose run --rm --no-deps php sh -lc "composer $(RUN_ARGS)"
+
+.PHONY: phpunit
+phpunit: ## execute project unit tests
+ docker-compose run --rm php sh -lc "./vendor/bin/phpunit $(conf)"
+
+.PHONY: style
+style: ## executes php analizers
+ docker-compose run --rm --no-deps php sh -lc './vendor/bin/phpstan analyse -l 6 -c phpstan.neon src'
+ docker-compose run --rm --no-deps php sh -lc './vendor/bin/psalm --config=psalm.xml'
+
+.PHONY: lint
+lint: ## checks syntax of PHP files
+ docker-compose run --rm --no-deps php sh -lc './vendor/bin/parallel-lint ./ --exclude vendor --exclude bin/.phpunit'
+
+.PHONY: logs
+logs: ## look for service logs
+ docker-compose logs -f $(RUN_ARGS)
+
+.PHONY: help
+help: ## Display this help message
+ @cat $(MAKEFILE_LIST) | grep -e "^[a-zA-Z_\-]*: *.*## *" | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
+
+.PHONY: php-shell
+php-shell: ## PHP shell
+ docker-compose run --rm php sh -l
+
+unit-tests: ## Run unit-tests suite
+ docker-compose run --rm php sh -lc 'vendor/bin/phpunit --testsuite unit-tests'
+
+static-analysis: style coding-standards ## Run phpstan, easycoding standarts code static analysis
+
+coding-standards: ## Run check and validate code standards tests
+ docker-compose run --rm --no-deps php sh -lc 'vendor/bin/ecs check src'
+ docker-compose run --rm --no-deps php sh -lc 'vendor/bin/phpmd src/ text phpmd.xml'
+
+coding-standards-fixer: ## Run code standards fixer
+ docker-compose run --rm --no-deps php sh -lc 'vendor/bin/ecs check src --fix'
+
+security-tests: ## The SensioLabs Security Checker
+ docker-compose run --rm --no-deps php sh -lc 'vendor/bin/security-checker security:check --end-point=http://security.sensiolabs.org/check_lock'
+
+.PHONY: test lint static-analysis phpunit coding-standards composer-validate
+test: build lint static-analysis phpunit coding-standards composer-validate stop ## Run all test suites
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0a7fc52
--- /dev/null
+++ b/README.md
@@ -0,0 +1,23 @@
+Unit test generator
+=====================
+
+Proof-of-concept component providing unit test generator.
+
+## Documentation
+This project can generate or single unit for one class or for all project with all needed mocks.
+
+## Getting started
+* For example if you want generate unit test for one class use following code:
+
+ `$testGenerator = new MicroModule\UnitTestGenerator\Service\TestClass();`
+ `$testGenerator->generate(FooService::class);`
+
+* For generate tests and mocks for all project use:
+
+ `$testGenerator = new MicroModule\UnitTestGenerator\Service\TestProject(realpath('src'), ['Migrations', 'Presentation', 'Exception']);`
+ `$testGenerator->generate();`
+
+ second argument is array of excluded folders
+
+## License
+This project is licensed under the MIT License - see the LICENSE file for details
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..4cb3411
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,29 @@
+{
+ "name": "micro-module/unit-test-generator",
+ "description": "POC for unit test generator",
+ "type": "library",
+ "license": "proprietary",
+ "require": {
+ "php": "^7.4",
+ "ext-json": "*",
+ "fzaninotto/faker": "^1.9"
+ },
+ "require-dev": {
+ "jakub-onderka/php-console-highlighter": "^0.4",
+ "jakub-onderka/php-parallel-lint": "^1.0",
+ "mockery/mockery": "^1.2",
+ "phpmd/phpmd": "^2.6",
+ "phpstan/phpstan": "^0.11",
+ "phpstan/phpstan-mockery": "^0.11",
+ "phpstan/phpstan-phpunit": "^0.11",
+ "phpunit/phpunit": "^8.0",
+ "roave/security-advisories": "dev-master",
+ "symplify/easy-coding-standard": "^5.4",
+ "vimeo/psalm": "^3.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "MicroModule\\UnitTestGenerator\\": "src/"
+ }
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..2330f58
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,12 @@
+version: "3.7"
+
+services:
+ php:
+ container_name: ${GENERATOR_COMPOSE_PROJECT_NAME}_php
+ user: 1000:1000
+ build:
+ context: .docker/php7.4-dev
+ volumes:
+ - ~/.composer/cache/:/.composer_cache/:rw
+ - ../:/packages:rw
+ working_dir: /packages/UnitTestGenerator
diff --git a/ecs.yml b/ecs.yml
new file mode 100644
index 0000000..6b7a996
--- /dev/null
+++ b/ecs.yml
@@ -0,0 +1,5 @@
+imports:
+ - { resource: 'vendor/symplify/easy-coding-standard/config/set/clean-code.yaml' }
+ - { resource: 'vendor/symplify/easy-coding-standard/config/set/symfony.yaml' }
+ - { resource: 'vendor/symplify/easy-coding-standard/config/set/php71.yaml' }
+ - { resource: 'vendor/symplify/easy-coding-standard/config/set/psr12.yaml' }
diff --git a/phpmd.xml b/phpmd.xml
new file mode 100644
index 0000000..700df16
--- /dev/null
+++ b/phpmd.xml
@@ -0,0 +1,28 @@
+
+
+ Ruleset for PHP Mess Detector that enforces coding standards
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..2b198c5
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,3 @@
+includes:
+ - vendor/phpstan/phpstan-mockery/extension.neon
+
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..1c226eb
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tests/unit
+
+
+
+
+
+
+
diff --git a/psalm.xml b/psalm.xml
new file mode 100644
index 0000000..13b78a0
--- /dev/null
+++ b/psalm.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Generator/AbstractGenerator.php b/src/Generator/AbstractGenerator.php
new file mode 100644
index 0000000..5dc63b2
--- /dev/null
+++ b/src/Generator/AbstractGenerator.php
@@ -0,0 +1,180 @@
+outClassName['fullyQualifiedClassName'];
+ }
+
+ /**
+ * Return source class path.
+ *
+ * @return string
+ */
+ public function getOutSourceFile(): string
+ {
+ return $this->outSourceFile;
+ }
+
+ /**
+ * Generates the code and writes it to a source file.
+ *
+ * @param string $file
+ *
+ * @return bool
+ */
+ public function write(string $file = ''): bool
+ {
+ if ('' === $file) {
+ $file = $this->outSourceFile;
+ }
+
+ if (file_exists($file)) {
+ echo "UnitTest '" . $file . "' already exists." . PHP_EOL;
+
+ return false;
+ }
+ $testCode = $this->generate();
+
+ if (null === $testCode) {
+ echo "UnitTest was not created for file '" . $file . "'." . PHP_EOL;
+
+ return false;
+ }
+
+ if (file_put_contents($file, $testCode)) {
+ echo "UnitTest '" . $file . "' created." . PHP_EOL;
+
+ return true;
+ }
+ echo "UnitTest was not created for file '" . $file . "'." . PHP_EOL;
+
+ return false;
+ }
+
+ /**
+ * Parse class name and return namespace, full and sort class name, test base class name.
+ *
+ * @param string $className
+ *
+ * @return string[]
+ */
+ protected function parseFullyQualifiedClassName(string $className): array
+ {
+ if (0 !== strpos($className, '\\')) {
+ $className = '\\' . $className . self::TEST_NAME_PREFIX;
+ }
+ $result = [
+ 'namespace' => '',
+ 'testBaseFullClassName' => '',
+ 'className' => $className,
+ 'fullyQualifiedClassName' => $className,
+ ];
+
+ if (false !== strpos($className, '\\')) {
+ $tmp = explode('\\', $className);
+ $result['className'] = $tmp[count($tmp) - 1];
+ $result['namespace'] = $this->arrayToName($tmp);
+
+ $testBaseFullClassName = $this->baseNamespace ? $this->baseNamespace . '\\' . $tmp[2] . '\Base' : $tmp[1] . '\Base';
+ $result['testBaseFullClassName'] = $testBaseFullClassName;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Build class name from exploded parts.
+ *
+ * @param string[] $parts
+ *
+ * @return string
+ */
+ protected function arrayToName(array $parts): string
+ {
+ $result = '';
+
+ if (count($parts) > 1) {
+ array_pop($parts);
+ $result = implode('\\', $parts);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Generate test code.
+ *
+ * @return string|null
+ */
+ abstract public function generate(): ?string;
+}
diff --git a/src/Generator/BaseTestGenerator.php b/src/Generator/BaseTestGenerator.php
new file mode 100644
index 0000000..4da1e74
--- /dev/null
+++ b/src/Generator/BaseTestGenerator.php
@@ -0,0 +1,66 @@
+baseNamespace = $baseNamespace;
+ $this->outClassName = $this->parseFullyQualifiedClassName($outClassName);
+ $this->outSourceFile = str_replace(
+ $this->outClassName['fullyQualifiedClassName'],
+ $this->outClassName['className'],
+ $outSourceFile
+ );
+ }
+
+ /**
+ * Generate base test class.
+ *
+ * @return string|null
+ *
+ * @throws Exception
+ */
+ public function generate(): ?string
+ {
+ $classTemplate = new Template(
+ sprintf(
+ '%s%stemplate%sTestBaseClass.tpl',
+ __DIR__,
+ DIRECTORY_SEPARATOR,
+ DIRECTORY_SEPARATOR
+ )
+ );
+
+ $classTemplate->setVar(
+ [
+ 'namespace' => trim($this->outClassName['namespace'], '\\'),
+ 'testClassName' => $this->outClassName['className'],
+ 'date' => date('Y-m-d'),
+ 'time' => date('H:i:s'),
+ ]
+ );
+
+ return $classTemplate->render();
+ }
+}
diff --git a/src/Generator/DataProviderGenerator.php b/src/Generator/DataProviderGenerator.php
new file mode 100644
index 0000000..1e9d155
--- /dev/null
+++ b/src/Generator/DataProviderGenerator.php
@@ -0,0 +1,972 @@
+ 'boolean',
+ DataTypeInterface::TYPE_INT => 'randomDigitNotNull',
+ DataTypeInterface::TYPE_FLOAT => 'randomFloat',
+ DataTypeInterface::TYPE_STRING => 'word',
+ DataTypeInterface::TYPE_MIXED => 'word',
+ DataTypeInterface::TYPE_ARRAY => 'words',
+ DataTypeInterface::TYPE_ARRAY_MIXED => 'words',
+ ];
+
+ private const FAKE_PROVIDERS = [
+ 'Lorem' => [
+ 'text'
+ ],
+ 'Person' => [
+ 'titleMale', // 'Mr.'
+ 'titleFemale', // 'Ms.'
+ 'name' => ['user', 'member'], // 'Dr. Zane Stroman'
+ 'firstName', // 'Maynard'
+ 'firstNameMale', // 'Maynard'
+ 'firstNameFemale', // 'Rachel'
+ 'lastName', // 'Zulauf'
+ ],
+ 'Address' => [
+ 'address',
+ 'cityPrefix', // 'Lake'
+ 'secondaryAddress', // 'Suite 961'
+ 'state', // 'NewMexico'
+ 'stateAbbr', // 'OH'
+ 'citySuffix', // 'borough'
+ 'streetSuffix', // 'Keys'
+ 'buildingNumber', // '484'
+ 'city', // 'West Judge'
+ 'streetName', // 'Keegan Trail'
+ 'streetAddress', // '439 Karley Loaf Suite 897'
+ 'postcode', // '17916'
+ 'address', // '8888 Cummings Vista Apt. 101, Susanbury, NY 95473'
+ 'country', // 'Falkland Islands (Malvinas)'
+ 'latitude', // 77.147489
+ 'longitude', // 86.211205
+ ],
+ 'Phone' => [
+ 'phoneNumber' => ['phone'], // '201-886-0269 x3767'
+ 'tollFreePhoneNumber', // '(888) 937-7238'
+ 'e164PhoneNumber', // '+27113456789'
+ ],
+ 'Company' => [
+ 'catchPhrase', // 'Monitored regional contingency'
+ 'company', // 'Bogan-Treutel'
+ 'companySuffix', // 'and Sons'
+ 'jobTitle', // 'Cashier'
+ ],
+ 'Text' => [
+ 'realText' => ['content', 'text', 'article', 'description']
+ ],
+ 'DateTime' => [
+ 'unixTime', // 58781813
+ 'dateTime', // DateTime('2008-04-25 08:37:17', 'UTC')
+ 'dateTimeAD', // DateTime('1800-04-29 20:38:49', 'Europe/Paris')
+ 'iso8601', // '1978-12-09T10:10:29+0000'
+ 'date', // '1979-06-09'
+ 'time', // '20:49:42'
+ 'dateTimeBetween', // DateTime('2003-03-15 02:00:49', 'Africa/Lagos')
+ 'dateTimeInInterval', // DateTime('2003-03-15 02:00:49', 'Antartica/Vostok')
+ 'dateTimeThisCentury', // DateTime('1915-05-30 19:28:21', 'UTC')
+ 'dateTimeThisDecade', // DateTime('2007-05-29 22:30:48', 'Europe/Paris')
+ 'dateTimeThisYear', // DateTime('2011-02-27 20:52:14', 'Africa/Lagos')
+ 'dateTimeThisMonth', // DateTime('2011-10-23 13:46:23', 'Antarctica/Vostok')
+ 'dayOfMonth', // '04'
+ 'dayOfWeek', // 'Friday'
+ 'month', // '06'
+ 'monthName', // 'January'
+ 'year', // '1993'
+ 'century', // 'VI'
+ 'timezone', // 'Europe/Paris'
+ ],
+ 'Internet' => [
+ 'email', // 'tkshlerin@collins.com'
+ 'safeEmail', // 'king.alford@example.org'
+ 'freeEmail', // 'bradley72@gmail.com'
+ 'companyEmail', // 'russel.durward@mcdermott.org'
+ 'freeEmailDomain', // 'yahoo.com'
+ 'safeEmailDomain', // 'example.org'
+ 'userName', // 'wade55'
+ 'password', // 'k&|X+a45*2['
+ 'domainName', // 'wolffdeckow.net'
+ 'domainWord', // 'feeney'
+ 'tld', // 'biz'
+ 'url', // 'http://www.skilesdonnelly.biz/aut-accusantium-ut-architecto-sit-et.html'
+ 'slug', // 'aut-repellat-commodi-vel-itaque-nihil-id-saepe-nostrum'
+ 'ipv4', // '109.133.32.252'
+ 'localIpv4', // '10.242.58.8'
+ 'ipv6', // '8e65:933d:22ee:a232:f1c1:2741:1f10:117c'
+ 'macAddress', // '43:85:B7:08:10:CA'
+ ],
+ 'UserAgent' => [
+ 'userAgent', // 'Mozilla/5.0 (Windows CE) AppleWebKit/5350 (KHTML, like Gecko) Chrome/13.0.888.0 Safari/5350'
+ 'chrome', // 'Mozilla/5.0 (Macintosh; PPC Mac OS X 10_6_5) AppleWebKit/5312 (KHTML, like Gecko) Chrome/14.0.894.0 Safari/5312'
+ 'firefox', // 'Mozilla/5.0 (X11; Linuxi686; rv:7.0) Gecko/20101231 Firefox/3.6'
+ 'safari', // 'Mozilla/5.0 (Macintosh; U; PPC Mac OS X 10_7_1 rv:3.0; en-US) AppleWebKit/534.11.3 (KHTML, like Gecko) Version/4.0 Safari/534.11.3'
+ 'opera', // 'Opera/8.25 (Windows NT 5.1; en-US) Presto/2.9.188 Version/10.00'
+ 'internetExplorer', // 'Mozilla/5.0 (compatible; MSIE 7.0; Windows 98; Win 9x 4.90; Trident/3.0)'
+ ],
+ 'Payment' => [
+ 'creditCardType', // 'MasterCard'
+ 'creditCardNumber' => ['creditCard', ], // '4485480221084675'
+ 'creditCardExpirationDate', // 04/13
+ 'creditCardExpirationDateString', // '04/13'
+ 'creditCardDetails', // array('MasterCard', '4485480221084675', 'Aleksander Nowak', '04/13')
+ 'iban', // 'IT31A8497112740YZ575DJ28BP4'
+ 'swiftBicNumber', // 'RZTIAT22263'
+ ],
+ 'Color' => [
+ 'color'
+ ],
+// 'File' => [
+// 'file'
+// ],
+ 'Image' => [
+ 'imageUrl',
+ 'image' => ['img', 'image', 'jpg'],
+ ],
+ 'Uuid' => [
+ 'uuid'
+ ],
+ 'Barcode' => [
+ 'barcode'
+ ],
+ 'Miscellaneous' => [
+ 'boolean', // false
+ 'md5', // 'de99a620c50f2990e87144735cd357e7'
+ 'sha1', // 'f08e7f04ca1a413807ebc47551a40a20a0b4de5c'
+ 'sha256', // '0061e4c60dac5c1d82db0135a42e00c89ae3a333e7c26485321f24348c7e98a5'
+ 'locale', // en_UK
+ 'countryCode', // UK
+ 'languageCode', // en
+ 'currencyCode', // EUR
+ 'emoji', // 😁
+ ],
+ 'Html' => [
+ 'randomHtml' => ['html']
+ ],
+ ];
+
+ /**
+ * Data providers code.
+ *
+ * @var mixed[]
+ */
+ protected $dataProviders = [];
+
+ /**
+ * Path to save dataProvider helper.
+ *
+ * @var string
+ */
+ private $dataProviderTestPath;
+
+ /**
+ * DataProvider namespace.
+ *
+ * @var string
+ */
+ private $dataProviderNamespace;
+
+ /**
+ * Project namespace.
+ *
+ * @var string
+ */
+ private $projectNamespace;
+
+ /**
+ * Fake data generator.
+ *
+ * @var Generator
+ */
+ private $faker;
+
+ /**
+ * Is throw exception if return type was not found.
+ *
+ * @var bool
+ */
+ private $returnTypeNotFoundThrowable = false;
+
+ /**
+ * Count of test data sets.
+ *
+ * @var int
+ */
+ private $countDataSets = 1;
+
+ /**
+ * Deep level to build data for mocked object.
+ *
+ * @var int
+ */
+ private $mockDeepLevel = 4;
+
+ /**
+ * TestGenerator constructor.
+ *
+ * @param string $dataProviderTestPath
+ * @param string $dataProviderNamespace
+ * @param string $baseNamespace
+ * @param string $projectNamespace
+ */
+ public function __construct(
+ string $dataProviderTestPath,
+ string $dataProviderNamespace,
+ string $baseNamespace,
+ string $projectNamespace
+ ) {
+ $this->dataProviderTestPath = $dataProviderTestPath;
+ $this->dataProviderNamespace = $dataProviderNamespace;
+ $this->baseNamespace = $baseNamespace;
+ $this->projectNamespace = $projectNamespace;
+ $this->faker = Factory::create();
+ }
+
+ /**
+ * Build and return dataProvider file path.
+ *
+ * @param string $className
+ *
+ * @return string[]
+ */
+ private function getDataProviderPathAndNamespace(string $className): array
+ {
+ $pos = strpos($className, $this->projectNamespace);
+
+ if (false === $pos) {
+ $dataProviderClassName = ucfirst(str_replace('\\', '', $className)) . self::DATA_PROVIDER_SUFFIX;
+ $dataProviderFilePath = $this->dataProviderTestPath . DIRECTORY_SEPARATOR . 'Common' . DIRECTORY_SEPARATOR . $dataProviderClassName . '.php';
+ $dataProviderFullClassName = '\\' .$this->dataProviderNamespace . '\\' . 'Common' . '\\' . $dataProviderClassName;
+ $dataProviderName = ucfirst(str_replace('\\', '', $dataProviderClassName));
+ $pos = strrpos($dataProviderFullClassName, '\\');
+
+ if (false === $pos) {
+ $pos = strlen($dataProviderFullClassName);
+ }
+ $dataProviderNamespace = substr($dataProviderFullClassName, 0, $pos);
+
+ return [$dataProviderFilePath, $dataProviderNamespace, $dataProviderClassName, $dataProviderFullClassName, $dataProviderName];
+ }
+ $tmpNamespace = substr($className, strlen($this->projectNamespace) + 1);
+ $tmpNamespaces = explode('\\', $tmpNamespace);
+ $tmpNamespace = implode('\\', array_slice($tmpNamespaces, 0, count($tmpNamespaces) - 1));
+ $tmpClassName = implode('\\', array_slice($tmpNamespaces, count($tmpNamespaces) - 1, 1));
+ $dataProviderFilePath = $this->dataProviderTestPath . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $tmpNamespace) . DIRECTORY_SEPARATOR . $tmpClassName . self::DATA_PROVIDER_SUFFIX . '.php';
+ $dataProviderClassName = $tmpClassName . self::DATA_PROVIDER_SUFFIX;
+ $dataProviderName = ucfirst(str_replace('\\', '', $dataProviderClassName));
+ $dataProviderFullClassName = '\\' .$this->dataProviderNamespace . '\\' . $tmpNamespace . '\\' . $dataProviderClassName;
+ $pos = strrpos($dataProviderFullClassName, '\\');
+
+ if (false === $pos) {
+ $pos = strlen($dataProviderFullClassName);
+ }
+ $dataProviderNamespace = substr($dataProviderFullClassName, 0, $pos);
+
+ return [$dataProviderFilePath, $dataProviderNamespace, $dataProviderClassName, $dataProviderFullClassName, $dataProviderName];
+ }
+
+ /**
+ * Generate and return DataProvider name
+ *
+ * @param string $className
+ *
+ * @return string
+ */
+ public function getDataProviderName(string $className): string
+ {
+ $pos = strpos($className, $this->projectNamespace);
+
+ if (false === $pos) {
+ $dataProviderClassName = ucfirst(str_replace('\\', '', $className)) . self::DATA_PROVIDER_SUFFIX;
+
+ return ucfirst(str_replace('\\', '', $dataProviderClassName));
+ }
+ $tmpNamespace = substr($className, strlen($this->projectNamespace) + 1);
+ $tmpNamespaces = explode('\\', $tmpNamespace);
+ $tmpClassName = implode('\\', array_slice($tmpNamespaces, count($tmpNamespaces) - 1, 1));
+ $dataProviderClassName = $tmpClassName . self::DATA_PROVIDER_SUFFIX;
+
+ return ucfirst(str_replace('\\', '', $dataProviderClassName));
+ }
+
+
+ /**
+ * Generate and return DataProvider method name
+ *
+ * @param ReflectionMethod $method
+ * @param string $dataProviderClassName
+ *
+ * @return string
+ */
+ public function getDataProviderMethodName(ReflectionMethod $method, string $dataProviderClassName): string
+ {
+ if ($method->isConstructor()) {
+ return self::DATA_PROVIDER_DEFAULT_ARGS;
+ }
+
+ return 'getDataFor' . ucfirst($method->getName()) . 'Method';
+ }
+
+ /**
+ * Generate and save test dataProviders.
+ *
+ * @return string|null
+ *
+ * @throws Exception
+ */
+ public function generate(): ?string
+ {
+ $dataProviderFiles = [];
+ $dataProviders = [];
+ $dataProviderFileInitialized = [];
+
+ foreach ($this->dataProviders as $dataProviderName => $dataProvider) {
+ $testClassName = $dataProvider['testClassName'];
+ $dataProviderFilepath = $dataProvider['dataProviderFilepath'];
+ $dataProviderNamespace = $dataProvider['dataProviderNamespace'];
+ $dataProviderClassName = $dataProvider['dataProviderClassName'];
+ $dataProviderFullClassName = $dataProvider['dataProviderFullClassName'];
+ $dataProviderName = $dataProvider['dataProviderName'];
+
+ if (!isset($dataProviders[$dataProviderFilepath])) {
+ $dataProviders[$dataProviderFilepath] = [
+ 'testClassName' => $testClassName,
+ 'className' => $dataProviderClassName,
+ 'fullClassName' => $dataProviderFullClassName,
+ 'namespace' => $dataProviderNamespace,
+ 'methods' => [],
+ ];
+ }
+ $defaultArgs = null;
+
+ if (isset($dataProvider['dataProviderMethods'][self::DATA_PROVIDER_DEFAULT_ARGS])) {
+ $defaultArgs = $dataProvider['dataProviderMethods'][self::DATA_PROVIDER_DEFAULT_ARGS];
+ unset($dataProvider['dataProviderMethods'][self::DATA_PROVIDER_DEFAULT_ARGS]);
+ }
+
+ foreach ($dataProvider['dataProviderMethods'] as $dataProviderMethodName => $dataProviderArgs) {
+ if (null !== $defaultArgs) {
+ if ($dataProviderArgs) {
+ for ($i = 0; $i < $this->countDataSets; ++$i) {
+ $dataProviderArgs[$i][0] = array_merge($defaultArgs[$i][0], $dataProviderArgs[$i][0]);
+ $dataProviderArgs[$i][1] = array_merge($defaultArgs[$i][1], $dataProviderArgs[$i][1]);
+ }
+ } else {
+ $dataProviderArgs = $defaultArgs;
+ }
+ }
+
+ if (!isset($dataProviderFileInitialized[$dataProviderFilepath]) && file_exists($dataProviderFilepath)) {
+ $methods = $this->parseMethodsFromSource($dataProviderFilepath);
+
+ foreach ($methods as $method) {
+ $dataProviderFiles[$dataProviderFilepath][] = $method;
+ }
+ $dataProviderFileInitialized[$dataProviderFilepath] = true;
+ }
+
+ if (!isset($dataProviderFiles[$dataProviderFilepath])) {
+ $dataProviderFiles[$dataProviderFilepath] = [];
+ }
+
+ if (in_array($dataProviderMethodName, $dataProviderFiles[$dataProviderFilepath], true)) {
+ continue;
+ }
+ $dataProviderTemplate = new Template(
+ sprintf(
+ '%s%stemplate%sDataProviderMethod.tpl',
+ __DIR__,
+ DIRECTORY_SEPARATOR,
+ DIRECTORY_SEPARATOR
+ )
+ );
+ $dataProviderTemplate->setVar([
+ 'testClassName' => $testClassName,
+ 'dataProviderName' => $dataProviderName,
+ 'dataProviderClassName' => $dataProviderClassName,
+ 'dataProviderMethodName' => $dataProviderMethodName,
+ 'dataProviderArgs' => str_replace("\n", "\n\t\t\t", $this->varExport($dataProviderArgs))
+ ]);
+ $dataProviders[$dataProviderFilepath]['methods'][] = $dataProviderTemplate->render();
+ }
+ }
+
+ foreach ($dataProviders as $dataProviderFilepath => $dataProvider) {
+ $dataProviderCode = implode('', $dataProvider['methods']);
+
+ if (!file_exists($dataProviderFilepath)) {
+ $dataProviderCode = $this->generateNewDataProvider($dataProvider['namespace'], $dataProvider['className'], $dataProviderCode);
+ }
+ $this->saveFile($dataProviderFilepath, $dataProviderCode);
+ }
+
+ return null;
+ }
+
+ /**
+ * Generate new dataProvider.
+ *
+ * @param string $namespace
+ * @param string $className
+ * @param string $methods
+ *
+ * @return string
+ *
+ * @throws Exception
+ */
+ private function generateNewDataProvider(string $namespace, string $className, string $methods): string
+ {
+ $classTemplate = new Template(
+ sprintf(
+ '%s%stemplate%sDataProvider.tpl',
+ __DIR__,
+ DIRECTORY_SEPARATOR,
+ DIRECTORY_SEPARATOR
+ )
+ );
+ $classTemplate->setVar([
+ 'namespace' => trim($namespace, '\\'),
+ 'className' => $className,
+ 'methods' => $methods,
+ 'date' => date('Y-m-d'),
+ 'time' => date('H:i:s'),
+ ]);
+
+ return $classTemplate->render();
+ }
+
+ /**
+ * Save code to file, if file exist append code, if no, check if folder exist and create if needed.
+ *
+ * @param string $filepath
+ * @param string $code
+ *
+ * @throws CodeExtractException
+ */
+ private function saveFile(string $filepath, string $code): void
+ {
+ $pathInfo = pathinfo($filepath);
+
+ if (!file_exists($pathInfo['dirname'])) {
+ if (!mkdir($concurrentDirectory = $pathInfo['dirname'], 0755, true) && !is_dir($concurrentDirectory)) {
+ throw new RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory));
+ }
+ }
+
+ if (file_exists($filepath)) {
+ $existCode = file_get_contents($filepath);
+
+ if (false === $existCode) {
+ throw new CodeExtractException(sprintf('Code can not be extract from source \'%s\'.', $filepath));
+ }
+ $pos = strrpos($existCode, "\n}");
+
+ if (false === $pos) {
+ $pos = strlen($existCode);
+ }
+ $existCode = substr($existCode, 0, $pos);
+ $code = $existCode . $code . "\n}";
+ }
+
+ file_put_contents($filepath, $code);
+ }
+
+ /**
+ * Add new DataProvider
+ *
+ * @param ReflectionClass $reflectionClass
+ * @param ReflectionMethod $reflectionMethod
+ *
+ * @return string
+ *
+ * @throws ReflectionException
+ * @throws ReturnTypeNotFoundException
+ */
+ public function addDataProviderMethod(ReflectionClass $reflectionClass, ReflectionMethod $reflectionMethod): string
+ {
+ $className = $reflectionClass->getName();
+ [
+ $dataProviderFilepath,
+ $dataProviderNamespace,
+ $dataProviderClassName,
+ $dataProviderFullClassName,
+ $dataProviderName
+ ] = $this->getDataProviderPathAndNamespace($className);
+ $dataProviderMethodName = $this->getDataProviderMethodName($reflectionMethod, $dataProviderClassName);
+
+ if (!isset($this->dataProviders[$dataProviderName])) {
+ $this->dataProviders[$dataProviderName] = [
+ 'testClassName' => $className,
+ 'dataProviderFilepath' => $dataProviderFilepath,
+ 'dataProviderNamespace' => $dataProviderNamespace,
+ 'dataProviderClassName' => $dataProviderClassName,
+ 'dataProviderFullClassName' => $dataProviderFullClassName,
+ 'dataProviderName' => $dataProviderName,
+ 'dataProviderMethods' => [],
+ ];
+ }
+
+ if (!isset($this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName])) {
+ $this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName] = [];
+
+ if (!$reflectionMethod->isConstructor()) {
+ for ($i = 0; $i < $this->countDataSets; ++$i) {
+ $fakeData = $this->getFakeDataForMethodReturnType($reflectionMethod);
+
+ if (null === $fakeData) {
+ break;
+ }
+ [$methodName, $fakeValue, $fakeTimes] = $fakeData;
+ $this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName][$i][0][$methodName] = $fakeValue;
+ $this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName][$i][1][$methodName] = $fakeTimes;
+ }
+ }
+ }
+
+ return $dataProviderMethodName;
+ }
+
+ /**
+ * Generate and return fake data by method return type.
+ *
+ * @param ReflectionMethod $method
+ *
+ * @return mixed[]|null
+ *
+ * @throws ReflectionException
+ * @throws ReturnTypeNotFoundException
+ */
+ private function getFakeDataForMethodReturnType(ReflectionMethod $method): ?array
+ {
+ $typeIsArray = false;
+ $type = $this->getReturnFromAnnotation($method, false, true);
+
+ if (null === $type) {
+ $type = $this->getMethodReturnType($method);
+
+ if ($type === DataTypeInterface::TYPE_VOID) {
+ return null;
+ }
+
+ if (null === $type) {
+ $type = DataTypeInterface::TYPE_MIXED;
+ }
+ }
+ $originType = $type;
+
+ if (false !== strpos($type, '[]')) {
+ $type = $this->getClassNameFromComplexAnnotationName($type, $method->getDeclaringClass());
+ $originType = str_replace('[]', '', $originType);
+ $typeIsArray = true;
+ }
+ $methodName = $method->getName();
+
+ if (in_array($type, self::METHODS_RETURN_SELF, true)) {
+ $type = $method->getDeclaringClass()->getName();
+ }
+
+ if (class_exists($type) || interface_exists($type)) {
+ [, $fakeValue, $fakeTimes] = $this->getMockedDataProvider($type, true, 1);
+ $fakeValue['className'] = $type;
+ $fakeTimes['className'] = $type;
+
+ if ($typeIsArray) {
+ $fakeValue = [$fakeValue];
+ $fakeTimes = [$fakeTimes];
+ }
+
+ return [$methodName, $fakeValue, $fakeTimes];
+ }
+ $fakeValue = $this->getFakeValueByName($methodName);
+
+ if (null === $fakeValue) {
+ $fakeValue = $this->getFakeValueByType($originType);
+ }
+
+ if ($typeIsArray) {
+ $fakeValue = [$fakeValue];
+ }
+
+ return [$methodName, $fakeValue, 0];
+ }
+
+ /**
+ * Add to data provider new argument.
+ *
+ * @param ReflectionClass $reflectionClass
+ * @param ReflectionParameter $parameter
+ * @param string $dataProviderMethodName
+ *
+ * @throws ReflectionException
+ * @throws ReturnTypeNotFoundException
+ */
+ public function addDataProviderArgument(
+ ReflectionClass $reflectionClass,
+ ReflectionParameter $parameter,
+ string $dataProviderMethodName
+ ): void {
+ $dataProviderName = $this->getDataProviderName($reflectionClass->getName());
+
+ for ($i=0; $i < $this->countDataSets; ++$i) {
+ [$paramName, $fakeValue, $fakeTimes] = $this->getFakeData($parameter);
+
+ if (isset($this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName][$i][0][$paramName])) {
+ return;
+ }
+
+ if (!isset($this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName][$i])) {
+ $this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName][$i] = [
+ [],
+ []
+ ];
+ }
+ $this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName][$i][0][$paramName] = $fakeValue;
+
+ if (null !== $fakeTimes) {
+ $this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName][$i][1][$paramName] = $fakeTimes;
+ }
+ }
+ }
+
+ /**
+ * Return, if exists, dataProvider structure.
+ *
+ * @param string $dataProviderName
+ *
+ * @return mixed[]|null
+ */
+ public function getDataProvider(string $dataProviderName): ?array
+ {
+ return $this->dataProviders[$dataProviderName] ?: null;
+ }
+
+ /**
+ * Build and return fake data.
+ *
+ * @param ReflectionParameter $parameter
+ *
+ * @return mixed[]
+ *
+ * @throws ReflectionException
+ * @throws ReturnTypeNotFoundException
+ */
+ private function getFakeData(ReflectionParameter $parameter): array
+ {
+ $type = $parameter->getType();
+
+ if (null !== $type) {
+ $type = $type->getName();
+ }
+
+ if (null === $type) {
+ try {
+ $type = $this->getReturnFromAnnotation($parameter->getDeclaringFunction());
+ } catch (ReturnTypeNotFoundException $e) {
+ if ($this->returnTypeNotFoundThrowable) {
+ throw $e;
+ }
+ $type = DataTypeInterface::TYPE_MIXED;
+ }
+ }
+
+ if (class_exists($type) || interface_exists($type)) {
+ return $this->getMockedDataProvider($type, true, 1);
+ }
+ $paramName = $parameter->getName();
+ $fakeValue = $this->getFakeValueByName($paramName);
+
+ if (null === $fakeValue) {
+ $fakeValue = $this->getFakeValueByType($type);
+ }
+
+ return [$paramName, $fakeValue, null];
+ }
+
+ /**
+ * Analyze and generate fake value for param.
+ *
+ * @param string $paramName
+ *
+ * @return mixed|null
+ */
+ private function getFakeValueByName(string $paramName)
+ {
+ foreach (self::FAKE_PROVIDERS as $type => $fakeProvider) {
+ foreach ($fakeProvider as $subFormatter => $formatter) {
+ if (is_array($formatter)) {
+ $formatters = $formatter;
+ $formatter = $subFormatter;
+ array_unshift($formatters, $subFormatter);
+ } else {
+ $formatters = [$formatter];
+ }
+
+ foreach ($formatters as $pattern) {
+ if (false !== stripos($paramName, $pattern)) {
+ $fakeValue = $this->faker->{$formatter};
+
+ if ($fakeValue instanceof \DateTime) {
+ $fakeValue = $fakeValue->format(DATE_ATOM);
+ }
+
+ return $fakeValue;
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Analyze and generate fake value for param.
+ *
+ * @param string $paramType
+ *
+ * @return mixed|null
+ */
+ private function getFakeValueByType(string $paramType)
+ {
+ if (isset(self::FAKE_BASE_PROVIDERS[$paramType])) {
+ return $this->faker->{self::FAKE_BASE_PROVIDERS[$paramType]};
+ }
+
+ return null;
+ }
+
+ /**
+ * Build data provider structure for mock.
+ *
+ * @param string $className
+ * @param bool $mockDeeper
+ * @param int $level
+ *
+ * @return mixed[]
+ *
+ * @throws ReflectionException
+ * @throws ReturnTypeNotFoundException
+ */
+ private function getMockedDataProvider(string $className, bool $mockDeeper = false, int $level = 0): array
+ {
+ $class = new ReflectionClass($className);
+ $mockArguments = [];
+ $mockTimes = ['times' => 0];
+ $arguments = [];
+ $argumentTimes = [];
+
+ foreach ($class->getMethods() as $method) {
+ $methodName = $method->getName();
+
+ if (
+ $method->isConstructor() ||
+ $method->isDestructor() ||
+ $method->isProtected() ||
+ $method->isPrivate() ||
+ $method->isStatic() ||
+ in_array($className, self::$excludeMethods, true) ||
+ in_array($methodName, self::$excludeMethods, true) ||
+ (isset(self::$excludeMethods[$className]) && in_array($methodName, self::$excludeMethods[$className], true)) ||
+ (
+ isset(self::$excludeMethods[$className]['all_except']) &&
+ !in_array($methodName, self::$excludeMethods[$className]['all_except'], true)
+ )
+ ) {
+ continue;
+ }
+ $fakeParam = $methodName;
+
+ if (isset($arguments[$fakeParam])) {
+ $mockArguments[$methodName] = $arguments[$fakeParam];
+ $mockTimes[$methodName] = $argumentTimes[$fakeParam];
+ continue;
+ }
+
+ if (in_array($fakeParam, self::$selfMethods, true)) {
+ $fakeParam = $className;
+
+ if (isset($arguments[$fakeParam])) {
+ $mockArguments[$methodName] = $arguments[$fakeParam];
+ $mockTimes[$methodName] = $argumentTimes[$fakeParam];
+ continue;
+ }
+ }
+ $fakeValue = null;
+ $fakeTimes = 0;
+ $typeIsArray = false;
+
+ if (!$mockDeeper || $level > $this->mockDeepLevel) {
+ continue;
+ }
+ $returnType = $this->getMethodReturnType($method);
+
+ if (
+ $returnType !== DataTypeInterface::TYPE_ARRAY_MIXED &&
+ false !== strpos($returnType, '[]')
+ ) {
+ $returnType = str_replace('[]', '', $returnType);
+ $typeIsArray = true;
+ }
+
+ if (class_exists($returnType) || interface_exists($returnType)) {
+ if (isset($arguments[$returnType])) {
+ $mockArguments[$methodName] = $arguments[$returnType];
+ $mockTimes[$methodName] = $argumentTimes[$returnType];
+ continue;
+ }
+
+ if ($returnType === $className) {
+ continue;
+ }
+ [, $fakeValue, $fakeTimes] = $this->getMockedDataProvider($returnType, true, $level + 1);
+ } else {
+ $returnType = $this->findAndReturnClassNameFromUseStatement($returnType, $method->getDeclaringClass());
+
+ if (null !== $returnType) {
+ if (isset($arguments[$returnType])) {
+ $mockArguments[$methodName] = $arguments[$returnType];
+ $mockTimes[$methodName] = $argumentTimes[$returnType];
+ continue;
+ }
+ [, $fakeValue, $fakeTimes] = $this->getMockedDataProvider($returnType, true, $level + 1);
+ }
+ }
+
+ if (null === $fakeValue) {
+ $fakeValue = $this->getFakeValueByName($fakeParam);
+ }
+
+ if (null === $fakeValue) {
+ $fakeParam = $this->getMethodReturnType($method);
+
+ if (isset($arguments[$fakeParam])) {
+ $mockArguments[$methodName] = $arguments[$fakeParam];
+ $mockTimes[$methodName] = $argumentTimes[$fakeParam];
+ continue;
+ }
+
+ if (class_exists($fakeParam) || interface_exists($fakeParam)) {
+ $fakeValue = $this->getFakeValueByName($fakeParam);
+ }
+
+ if (null === $fakeValue) {
+ $fakeValue = $this->getFakeValueByType($fakeParam);
+ }
+ }
+ $arguments[$fakeParam] = $fakeValue;
+ $argumentTimes[$fakeParam] = $fakeTimes;
+
+ if ($typeIsArray) {
+ $fakeValue = [$fakeValue];
+
+ if (isset($fakeTimes['times'])) {
+ $times = $fakeTimes['times'];
+ unset($fakeTimes['times']);
+ $fakeTimes = ['times' => $times, 'mockTimes' => [$fakeTimes]];
+ }
+ }
+ $mockArguments[$methodName] = $fakeValue;
+ $mockTimes[$methodName] = $fakeTimes;
+ }
+ $pos = strrpos($className, '\\');
+ $paramName = $pos ? substr($className, ++$pos) : $className;
+
+ return [$paramName, $mockArguments, $mockTimes];
+ }
+
+ /**
+ * PHP var_export() with short array syntax (square brackets) indented 2 spaces.
+ *
+ * @param mixed $expression
+ *
+ * @return string
+ */
+ private function varExport($expression): string
+ {
+ $export = var_export($expression, TRUE);
+ $patterns = [
+ "/array \(/" => '[',
+ "/^([ ]*)\)(,?)$/m" => '$1]$2',
+ "/=>[ ]?\n[ ]+\[/" => '=> [',
+ "/([ ]*)(\'[^\']+\') => ([\[\'])/" => '$1$2 => $3',
+ ];
+ $export = preg_replace(array_keys($patterns), array_values($patterns), $export);
+
+ return $export;
+ }
+
+ /**
+ * Set method names, that should return self value.
+ *
+ * @param string[] $selfMethods
+ */
+ public static function setSelfMethods(array $selfMethods): void
+ {
+ self::$selfMethods = $selfMethods;
+ }
+
+ /**
+ * Set method names, that should be excluded.
+ *
+ * @param string[] $excludeMethods
+ */
+ public static function setExcludeMethods(array $excludeMethods): void
+ {
+ self::$excludeMethods = $excludeMethods;
+ }
+}
diff --git a/src/Generator/DataTypeInterface.php b/src/Generator/DataTypeInterface.php
new file mode 100644
index 0000000..25332a2
--- /dev/null
+++ b/src/Generator/DataTypeInterface.php
@@ -0,0 +1,31 @@
+getDocComment();
+
+ if ($docComment) {
+ preg_match_all('/@return (.*)$/Um', $docComment, $return);
+ }
+
+ if (!isset($return[1][0])) {
+ if (!$strictMode) {
+ return null;
+ }
+ $reflection = $refMethod->getDeclaringClass();
+
+ throw new ReturnTypeNotFoundException(
+ sprintf(
+ 'In class \'%s\' could not find annotation comment for method \'%s\' in source \'%s\'.',
+ $reflection->getName(),
+ $refMethod->getName(),
+ $reflection->getFileName()
+ )
+ );
+ }
+ $return = trim($return[1][0], ' \\');
+
+ if (false !== strpos($return, '|')) {
+ $return = explode('|', $return);
+ $return = (count($return) > 1 && $return[0] === DataTypeInterface::TYPE_NULL) ? $return[1] : $return[0];
+ }
+ $return = trim($return);
+
+ if (false !== strpos($return, ' ')) {
+ $return = explode(' ', $return)[0];
+ }
+
+ if (false === $returnOrigin && false !== strpos($return, '[]')) {
+ return DataTypeInterface::TYPE_ARRAY;
+ }
+
+ return $return;
+ }
+
+ /**
+ * Find and return method param types from annotation.
+ *
+ * @param ReflectionMethod $refMethod
+ * @param bool $strictMode
+ * @param bool $returnOrigin
+ *
+ * @return string[]|null
+ *
+ * @throws ReturnTypeNotFoundException
+ */
+ protected function getParamsFromAnnotation(
+ ReflectionMethod $refMethod,
+ bool $strictMode = true,
+ bool $returnOrigin = false
+ ): ?array {
+ $annotationParams = [];
+ $docComment = $refMethod->getDocComment();
+
+ if (false === $docComment) {
+ if (!$strictMode) {
+ return null;
+ }
+ $reflection = $refMethod->getDeclaringClass();
+
+ throw new ReturnTypeNotFoundException(
+ sprintf(
+ 'In class \'%s\' could not find annotation comment for method \'%s\' in source \'%s\'.',
+ $reflection->getName(),
+ $refMethod->getName(),
+ $reflection->getFileName()
+ )
+ );
+ }
+
+ if (false !== strpos($docComment, '@inheritdoc')) {
+ try {
+ $docComment = $refMethod->getPrototype()->getDocComment();
+ } catch (ReflectionException $e) {
+ if (!$strictMode) {
+ return null;
+ }
+ $reflection = $refMethod->getDeclaringClass();
+
+ throw new ReturnTypeNotFoundException(
+ sprintf(
+ 'In class \'%s\' find \'@inheritdoc\' annotation comment, but prototype does not exists, for method \'%s\' in source \'%s\'.',
+ $reflection->getName(),
+ $refMethod->getName(),
+ $reflection->getFileName()
+ )
+ );
+ }
+ }
+
+ if ($docComment) {
+ preg_match_all('/@param (.*?) (.*)$/Um', $docComment, $annotationParams);
+ }
+
+ if (!isset($annotationParams[1][0])) {
+ if (!$strictMode) {
+ return null;
+ }
+ $reflection = $refMethod->getDeclaringClass();
+
+ throw new ReturnTypeNotFoundException(
+ sprintf(
+ 'In class \'%s\' could not find annotation comment for method \'%s\' in source \'%s\'.',
+ $reflection->getName(),
+ $refMethod->getName(),
+ $reflection->getFileName()
+ )
+ );
+ }
+ $annotationParams = $annotationParams[1];
+
+ foreach ($annotationParams as &$annotationParam) {
+ $annotationParam = trim($annotationParam, ' \\');
+
+ if (false !== strpos($annotationParam, '|')) {
+ $annotationParam = explode('|', $annotationParam);
+ $annotationParam = (count($annotationParam) > 1 && $annotationParam[0] === DataTypeInterface::TYPE_NULL) ? $annotationParam[1] : $annotationParam[0];
+ }
+ $annotationParam = trim($annotationParam);
+
+ if (false !== strpos($annotationParam, ' ')) {
+ $annotationParam = explode(' ', $annotationParam)[0];
+ }
+
+ if (false === $returnOrigin && false !== strpos($annotationParam, '[]')) {
+ $annotationParam = DataTypeInterface::TYPE_ARRAY;
+ }
+ }
+
+ return $annotationParams;
+ }
+
+ /**
+ * Parse and find all parent classes and traits.
+ *
+ * @param ReflectionClass $reflection
+ *
+ * @return mixed[]
+ */
+ public function getParentClassesAndTraits(ReflectionClass $reflection): array
+ {
+ $traitsNames = [];
+ $parentClasses = [];
+ $recursiveClasses = static function (ReflectionClass $class) use (&$recursiveClasses, &$traitsNames, &$parentClasses): void {
+ if (false !== $class->getParentClass()) {
+ $parentClass = $class->getParentClass()->getName();
+
+ if (in_array($parentClass, $parentClasses, true)) {
+ return;
+ }
+ $parentClasses[] = $parentClass;
+ $recursiveClasses($class->getParentClass());
+ } else {
+ $reflectionTraits = $class->getTraitNames();
+
+ if ($reflectionTraits) {
+ $traitsNames = array_merge($traitsNames, $reflectionTraits);
+ }
+ }
+ };
+ $recursiveClasses($reflection);
+
+ return [$parentClasses, $traitsNames];
+ }
+
+ /**
+ * Return all namespaces from parent classes and traits.
+ *
+ * @param ReflectionClass $reflection
+ *
+ * @return string[]
+ *
+ * @throws CodeExtractException
+ * @throws FileNotExistsException
+ * @throws ReflectionException
+ */
+ public function getNamespacesFromParentClassesAndTraits(ReflectionClass $reflection): array
+ {
+ $namespaces = [];
+ [$parentClasses, $extendedTraits] = $this->getParentClassesAndTraits($reflection);
+ unset($parentClasses);
+
+ if (!$extendedTraits) {
+ return $namespaces;
+ }
+
+ foreach ($extendedTraits as $trait) {
+ $reflection = new ReflectionClass($trait);
+ $filename = $reflection->getFileName();
+
+ if (false === $filename) {
+ throw new FileNotExistsException(sprintf('Trait \'%s\' does not exists.', $trait));
+ }
+ $namespaces[] = $this->getNamespacesFromSource($filename);
+ }
+
+ return array_merge(...$namespaces);
+ }
+
+ /**
+ * Return all namespaces source.
+ *
+ * @param string $sourceName
+ *
+ * @return string[]
+ *
+ * @throws CodeExtractException
+ */
+ public function getNamespacesFromSource(string $sourceName): array
+ {
+ if (isset($this->namespaces[$sourceName])) {
+ return $this->namespaces[$sourceName];
+ }
+ $namespaces = [];
+ $sourceCode = file_get_contents($sourceName);
+
+ if (false === $sourceCode) {
+ throw new CodeExtractException(sprintf('Code can not be extract from source \'%s\'.', $sourceName));
+ }
+ $tokens = token_get_all($sourceCode);
+ $iMax = count($tokens);
+
+ for ($i = 0; $i < $iMax; ++$i) {
+ $token = $tokens[$i];
+
+ if (is_array($token) && T_USE === $token[0]) {
+ $y = 0;
+ ++$i;
+ $namespace = [];
+ $alias = false;
+ $namespaceFinish = false;
+
+ foreach (array_slice($tokens, $i) as $y => $useToken) {
+ if (
+ is_array($useToken) &&
+ (
+ T_WHITESPACE === $useToken[0] ||
+ T_NS_SEPARATOR === $useToken[0]
+ )
+ ) {
+ continue;
+ }
+
+ if (is_array($useToken) && T_STRING === $useToken[0]) {
+ $useToken[1] = trim($useToken[1]);
+ if (!$namespaceFinish) {
+ $namespace[] = $useToken[1];
+ } else {
+ $alias = $useToken[1];
+ }
+ } elseif (is_string($useToken)) {
+ if (',' === $useToken) {
+ if (false === $alias) {
+ $alias = end($namespace);
+ }
+ $namespace = implode('\\', $namespace);
+ $namespaces[$alias] = $namespace;
+ $namespace = [];
+ $alias = false;
+ $namespaceFinish = false;
+ } elseif (';' === $useToken) {
+ break;
+ }
+ } elseif (is_array($useToken) && T_AS === $useToken[0]) {
+ $namespaceFinish = true;
+ }
+ }
+
+ if (false === $alias) {
+ $alias = end($namespace);
+ }
+ $namespace = implode('\\', $namespace);
+ $namespaces[$alias] = $namespace;
+
+ $i += $y;
+ }
+ if (is_array($token) && T_CLASS === $token[0]) {
+ break;
+ }
+ }
+ $this->namespaces[$sourceName] = $namespaces;
+
+ return $this->namespaces[$sourceName];
+ }
+
+ /**
+ * Parse the use statements from read source by
+ * tokenizing and reading the tokens. Returns
+ * an array of use statements and aliases.
+ *
+ * @param ReflectionClass $reflection
+ *
+ * @return mixed[]
+ *
+ * @throws CodeExtractException
+ * @throws FileNotExistsException
+ */
+ private function parseUseStatements(ReflectionClass $reflection): array
+ {
+ $filename = $reflection->getFileName();
+
+ if (false === $filename) {
+ throw new FileNotExistsException(sprintf('Trait \'%s\' does not exists.', $reflection->getName()));
+ }
+ $sourceCode = file_get_contents($filename);
+
+ if (false === $sourceCode) {
+ throw new CodeExtractException(sprintf('Code can not be extract from source \'%s\'.', $filename));
+ }
+ $tokens = token_get_all($sourceCode);
+ $builtNamespace = '';
+ $buildingNamespace = false;
+ $matchedNamespace = false;
+ $useStatements = [];
+ $record = false;
+ $currentUse = [
+ 'class' => '',
+ 'as' => '',
+ ];
+
+ foreach ($tokens as $token) {
+ if (T_NAMESPACE === $token[0]) {
+ $buildingNamespace = true;
+ if ($matchedNamespace) {
+ break;
+ }
+ }
+
+ if ($buildingNamespace) {
+ if (';' === $token) {
+ $buildingNamespace = false;
+
+ continue;
+ }
+ switch ($token[0]) {
+ case T_STRING:
+ case T_NS_SEPARATOR:
+ $builtNamespace .= $token[1];
+
+ break;
+ }
+
+ continue;
+ }
+
+ if (';' === $token || !is_array($token)) {
+ if ($record) {
+ $useStatements[] = $currentUse;
+ $record = false;
+ $currentUse = [
+ 'class' => '',
+ 'as' => '',
+ ];
+ }
+
+ continue;
+ }
+
+ if (T_CLASS === $token[0]) {
+ break;
+ }
+
+ if (0 === strcasecmp($builtNamespace, $reflection->getNamespaceName())) {
+ $matchedNamespace = true;
+ }
+
+ if ($matchedNamespace) {
+ if (T_USE === $token[0]) {
+ $record = 'class';
+ }
+
+ if (T_AS === $token[0]) {
+ $record = 'as';
+ }
+
+ if ($record) {
+ switch ($token[0]) {
+ case T_STRING:
+ case T_NS_SEPARATOR:
+ $currentUse[$record] .= $token[1];
+
+ break;
+ }
+ }
+ }
+
+ if ($token[2] >= $reflection->getStartLine()) {
+ break;
+ }
+ }
+ // Make sure the as key has the name of the class even
+ // if there is no alias in the use statement.
+ foreach ($useStatements as &$useStatement) {
+ if (empty($useStatement['as'])) {
+ $useStatement['as'] = basename($useStatement['class']);
+ }
+ }
+
+ return $useStatements;
+ }
+
+ /**
+ * Return all methods from source.
+ *
+ * @param string $sourceName
+ *
+ * @return string[]
+ *
+ * @throws CodeExtractException
+ */
+ public function parseMethodsFromSource(string $sourceName): array
+ {
+ $methods = [];
+ $sourceCode = file_get_contents($sourceName);
+
+ if (false === $sourceCode) {
+ throw new CodeExtractException(sprintf('Code can not be extract from source \'%s\'.', $sourceName));
+ }
+ $tokens = token_get_all($sourceCode);
+ $iMax = count($tokens);
+
+ for ($i = 0; $i < $iMax; ++$i) {
+ $token = $tokens[$i];
+
+ if (is_array($token) && T_FUNCTION === $token[0]) {
+ $y = 0;
+ ++$i;
+ $method = '';
+
+ foreach (array_slice($tokens, $i) as $y => $methodToken) {
+ if (
+ is_array($methodToken) &&
+ (
+ T_WHITESPACE === $methodToken[0] ||
+ T_NS_SEPARATOR === $methodToken[0]
+ )
+ ) {
+ continue;
+ }
+
+ if (is_array($methodToken) && T_STRING === $methodToken[0]) {
+ $methodToken[1] = trim($methodToken[1]);
+ $method = $methodToken[1];
+ } elseif (is_string($methodToken)) {
+ if ('(' === $methodToken) {
+ break;
+ }
+ }
+ }
+ $methods[] = $method;
+ $i += $y;
+ }
+ }
+
+ return $methods;
+ }
+
+ /**
+ * Find and return source method return type.
+ *
+ * @param ReflectionMethod $reflectionMethod
+ *
+ * @return string
+ *
+ * @throws ReturnTypeNotFoundException
+ */
+ protected function getMethodReturnType(ReflectionMethod $reflectionMethod): string
+ {
+ $returnType = null;
+ $className = $reflectionMethod->getDeclaringClass()->getName();
+
+ if (in_array($reflectionMethod->getName(), self::METHOD_NAMES_RETURN_SELF, true)) {
+ $returnType = $className;
+ } else {
+ $returnType = $reflectionMethod->getReturnType();
+
+ if (null !== $returnType) {
+ $returnType = $returnType->getName();
+ }
+ }
+
+ if (!$returnType || $returnType === 'array') {
+ $returnType = $this->getReturnFromAnnotation($reflectionMethod, false, true);
+
+ if (null === $returnType) {
+ $returnType = DataTypeInterface::TYPE_MIXED;
+ }
+ }
+
+ if (in_array($returnType, self::METHODS_RETURN_SELF, true)) {
+ $returnType = $className;
+ }
+
+ return $returnType;
+ }
+
+ /**
+ * Process ReflectionClass from method annotation name and return class name.
+ *
+ * @param string $annotationName
+ * @param ReflectionClass $reflectionClass
+ *
+ * @return string
+ *
+ * @throws CodeExtractException
+ * @throws FileNotExistsException
+ */
+ protected function getClassNameFromComplexAnnotationName(string $annotationName, ReflectionClass $reflectionClass): string
+ {
+ $annotationName = str_replace('[]', '', $annotationName);
+ $useStatements = $this->parseUseStatements($reflectionClass);
+ $fullClassName = current(array_filter($useStatements, static function (array $element) use ($annotationName) {
+ if (!(class_exists($element['class']) || interface_exists($element['class']))) {
+ return false;
+ }
+ $reflection = new ReflectionClass($element['class']);
+
+ return ($element['as'] === $annotationName || $reflection->getShortName() === $annotationName) ?? true;
+ }));
+
+ if (is_array($fullClassName) && isset($fullClassName['class'])) {
+ $annotationName = $fullClassName['class'];
+ } else {
+ $annotationName = $reflectionClass->getNamespaceName() . '\\' . $annotationName;
+ }
+
+ return $annotationName;
+ }
+
+ /**
+ * Validate and try to find full class name from class use statement.
+ *
+ * @param string $className
+ * @param ReflectionClass $reflectionClass
+ *
+ * @return string|null
+ *
+ * @throws CodeExtractException
+ * @throws FileNotExistsException
+ */
+ protected function findAndReturnClassNameFromUseStatement(string $className, ReflectionClass $reflectionClass): ?string
+ {
+ if(
+ in_array($className, [
+ DataTypeInterface::TYPE_INT,
+ DataTypeInterface::TYPE_INTEGER,
+ DataTypeInterface::TYPE_FLOAT,
+ DataTypeInterface::TYPE_MIXED,
+ DataTypeInterface::TYPE_STRING,
+ DataTypeInterface::TYPE_BOOL,
+ DataTypeInterface::TYPE_BOOLEAN,
+ DataTypeInterface::TYPE_BOOL_TRUE,
+ DataTypeInterface::TYPE_BOOL_FALSE,
+ DataTypeInterface::TYPE_NULL,
+ DataTypeInterface::TYPE_VOID,
+ DataTypeInterface::TYPE_ARRAY_MIXED,
+ DataTypeInterface::TYPE_CLOSURE,
+ DataTypeInterface::TYPE_SELF,
+ ], true) ||
+ !$reflectionClass->getFileName()
+ ) {
+ return null;
+ }
+ $className = $this->getClassNameFromComplexAnnotationName($className, $reflectionClass);
+
+ if (!class_exists($className) && !interface_exists($className)) {
+ return null;
+ }
+
+ return $className;
+ }
+
+ /**
+ * @param ReflectionMethod $method
+ *
+ * @return Node|null
+ *
+ * @throws FileNotExistsException
+ */
+ protected function findAndReturnMethodAstStmtByName(ReflectionMethod $method)
+ {
+ $phpCodeGenerator = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
+ $reflectionClass = $method->getDeclaringClass();
+ $filename = $reflectionClass->getFileName();
+
+ if (false === $filename) {
+ throw new FileNotExistsException(sprintf('Trait \'%s\' does not exists.', $filename));
+ }
+ $sourceCode = file_get_contents($reflectionClass->getFileName());
+ $stmts = $phpCodeGenerator->parse($sourceCode);
+ $methodName = $method->getName();
+ $nodeFinder = new NodeFinder;
+ $stmtMethod = $nodeFinder->findFirst($stmts, function(Node $node) use ($methodName) {
+ return $node instanceof ClassMethod
+ && $node->name->toString() === $methodName;
+ });
+
+ return $stmtMethod;
+ }
+
+ /**
+ * @param ClassMethod $methodStmt
+ * @param string $methodName
+ *
+ * @return array
+ */
+ protected function findCallMethodsByNameFromAstStmt(ClassMethod $methodStmt, string $methodName): array
+ {
+ $nodeFinder = new NodeFinder;
+
+ return $nodeFinder->find($methodStmt, function(Node $node) use ($methodName) {
+ return $node instanceof Node\Expr\MethodCall
+ && $node->name->toString() === $methodName;
+ });
+ }
+
+ /**
+ * @param array $stmtMethods
+ * @param string $pattern
+ *
+ * @return array
+ */
+ protected function findCallMethodsByPatternFromAstStmts(array $stmtMethods, string $pattern): array
+ {
+ $nodeFinder = new NodeFinder;
+ $stmtCallMethods = [];
+
+ foreach ($stmtMethods as $stmtMethod) {
+ $stmtCallMethods[] = $nodeFinder->findFirst($stmtMethod, function(Node $node) use ($pattern) {
+ return $node instanceof Node\Expr\MethodCall
+ && preg_match($pattern, $node->name->toString());
+ });
+ }
+
+ return $stmtCallMethods;
+ }
+}
diff --git a/src/Generator/Helper/ReturnTypeNotFoundException.php b/src/Generator/Helper/ReturnTypeNotFoundException.php
new file mode 100644
index 0000000..68d1a43
--- /dev/null
+++ b/src/Generator/Helper/ReturnTypeNotFoundException.php
@@ -0,0 +1,14 @@
+isFinal() && !class_exists(BypassFinals::class)) {
+ throw new MockFinalClassException('Final class \'' . $reflection->getName() . '\' cannot be mocked, use interface instead.');
+ }
+
+ [$extendedClasses, $extendedTraits] = $this->getParentClassesAndTraits($reflection);
+ $methods = $reflection->getMethods();
+ $mockMethods = [];
+ $mockArgs = [];
+ $mockTimes = [];
+
+ foreach ($methods as $refMethod) {
+ $refMethodName = $refMethod->getName();
+
+ if (
+ $refMethod->isConstructor() ||
+ $refMethod->isDestructor() ||
+ $refMethod->isProtected() ||
+ $refMethod->isPrivate() ||
+ $refMethod->isStatic() ||
+ in_array($className, self::$excludeMethods, true) ||
+ in_array($refMethodName, self::$excludeMethods, true) ||
+ (isset(self::$excludeMethods[$className]) && in_array($refMethodName, self::$excludeMethods[$className], true)) ||
+ (
+ isset(self::$excludeMethods[$className]['all_except']) &&
+ !in_array($refMethodName, self::$excludeMethods[$className]['all_except'], true)
+ )
+ ) {
+ continue;
+ }
+ $refMethodDeclaringClassName = $refMethod->getDeclaringClass()->getName();
+
+ if (
+ in_array(strtolower($refMethodName), AbstractGenerator::EXCLUDE_DECLARING_METHODS, true) ||
+ in_array(explode('\\', trim($refMethodDeclaringClassName, '\\'))[0], AbstractGenerator::EXCLUDE_DECLARING_CLASSES_FOR_METHODS, true)
+ ) {
+ continue;
+ }
+ $returnType = null;
+ $mockArgs[] = '\'' . $refMethodName . '\' => \'\'';
+ $mockTimes[] = '\'' . $refMethodName . '\' => 0';
+
+ if (in_array($refMethodName, self::METHODS_RETURN_SELF, true)) {
+ $returnType = $className;
+ } else {
+ $returnType = $refMethod->getReturnType();
+
+ if (null !== $returnType) {
+ $returnType = $returnType->getName();
+ }
+ }
+
+ if (!$returnType) {
+ try {
+ $returnType = $this->getReturnFromAnnotation($refMethod);
+ } catch (ReturnTypeNotFoundException $e) {
+ if ($this->returnTypeNotFoundThrowable) {
+ throw $e;
+ }
+ $returnType = DataTypeInterface::TYPE_MIXED;
+ }
+ }
+
+ $mockMethod = 'if (array_key_exists(\'' . $refMethodName . '\', $mockTimes)) {
+ $mockMethod = $mock
+ ->shouldReceive(\'' . $refMethodName . '\');
+
+ if (null === $mockTimes[\'' . $refMethodName . '\']) {
+ $mockMethod->zeroOrMoreTimes();
+ } elseif (is_array($mockTimes[\'' . $refMethodName . '\'])) {
+ $mockMethod->times($mockTimes[\'' . $refMethodName . '\'][\'times\']);
+ } else {
+ $mockMethod->times($mockTimes[\'' . $refMethodName . '\']);
+ }' . "\r\n\t\t\t";
+
+ switch ($returnType) {
+ case DataTypeInterface::TYPE_INT:
+ case DataTypeInterface::TYPE_INTEGER:
+ case DataTypeInterface::TYPE_FLOAT:
+ case DataTypeInterface::TYPE_MIXED:
+ case DataTypeInterface::TYPE_STRING:
+ case DataTypeInterface::TYPE_BOOL:
+ case DataTypeInterface::TYPE_BOOLEAN:
+ case DataTypeInterface::TYPE_BOOL_TRUE:
+ case DataTypeInterface::TYPE_BOOL_FALSE:
+ $mockMethod .= '$mockMethod->andReturn($mockArgs[\'' . $refMethodName . '\']);';
+
+ break;
+
+ case DataTypeInterface::TYPE_NULL:
+ $mockMethod .= '$mockMethod->andReturnNull();';
+
+ break;
+
+ case DataTypeInterface::TYPE_VOID:
+ $mockMethod .= '';
+
+ break;
+
+ case DataTypeInterface::TYPE_ARRAY:
+ $returnType = $this->getReturnFromAnnotation($refMethod, false, true);
+
+ if (null === $returnType || $returnType === 'mixed[]') {
+ $returnType = DataTypeInterface::TYPE_ARRAY;
+ } else {
+ $returnType = $this->getClassNameFromComplexAnnotationName($returnType, $refMethod->getDeclaringClass());
+ }
+
+ if (class_exists($returnType) || interface_exists($returnType)) {
+ $filename = $reflection->getFileName();
+
+ if (false === $filename) {
+ throw new FileNotExistsException(sprintf('File \'%s\' does not exists.', $reflection->getName()));
+ }
+ $refNamespaces = $this->getNamespacesFromSource($filename);
+ $namespacesFromParentClassesAndTraits = $this->getNamespacesFromParentClassesAndTraits($reflection);
+ $namespaces = array_merge($parentNamespaces, $refNamespaces, $namespacesFromParentClassesAndTraits);
+ $refMockName = $mockGenerator->addMock($returnType, $reflection, $refMethod, $namespaces, $level, $parentClass);
+ $mockStructure = $mockGenerator->getMock($refMockName);
+
+ if (null === $mockStructure) {
+ throw new MockNotExistsException(sprintf('Mock \'%s\' does not exist.', $refMockName));
+ }
+ $mockMethodName = $mockStructure['mockMethodName'];
+ $mockShortName = $mockStructure['mockShortName'];
+ $mockMethod .= '$' . $mockShortName . 's = [];';
+ $mockMethod .= "\n\r\n\t\t\t" . 'foreach ($mockArgs[\'' . $refMethodName . '\'] as $i => $' . $mockShortName . ') {';
+ $mockMethod .= "\r\n\t\t\t\t$" . $mockShortName . 's[] = $this->' . $mockMethodName .
+ '($' . $mockShortName . ', $mockTimes[\'' . $refMethodName . '\'][\'mockTimes\'][$i]);' . "\r\n\t\t\t}\r\n\t\t\t" .
+ '$mockMethod->andReturn($' . $mockShortName . 's);';
+
+ break;
+ }
+
+ case DataTypeInterface::TYPE_ARRAY_MIXED:
+ $mockMethod .= '$mockMethod->andReturn($mockArgs[\'' . $refMethodName . '\']);';
+
+ break;
+
+ case DataTypeInterface::TYPE_CLOSURE:
+ case DataTypeInterface::TYPE_CALLABLE:
+ $mockMethod .= '$mockMethod->andReturn(function () { return true; });';
+
+ break;
+
+ case DataTypeInterface::TYPE_SELF:
+ case DataTypeInterface::TYPE_STATIC:
+ case DataTypeInterface::TYPE_THIS:
+ case $className:
+ $mockMethod .= '$mockMethod->andReturnSelf();';
+
+ break;
+
+ default:
+ if (
+ in_array($returnType, $extendedClasses, true) ||
+ in_array($returnType, $extendedTraits, true)
+ ) {
+ $mockMethod .= '$mockMethod->andReturnSelf();';
+
+ break;
+ }
+ $filename = $reflection->getFileName();
+
+ if (false === $filename) {
+ throw new FileNotExistsException(sprintf('File \'%s\' does not exists.', $reflection->getName()));
+ }
+ $refNamespaces = $this->getNamespacesFromSource($filename);
+ $namespacesFromParentClassesAndTraits = $this->getNamespacesFromParentClassesAndTraits($reflection);
+ $namespaces = array_merge($parentNamespaces, $refNamespaces, $namespacesFromParentClassesAndTraits);
+ $refMockName = $mockGenerator->addMock($returnType, $reflection, $refMethod, $namespaces, $level, $parentClass);
+ $mockStructure = $mockGenerator->getMock($refMockName);
+
+ if (null === $mockStructure) {
+ throw new MockNotExistsException(sprintf('Mock \'%s\' does not exist.', $refMockName));
+ }
+ $mockMethodName = $mockStructure['mockMethodName'];
+ $mockShortName = $mockStructure['mockShortName'];
+ $mockMethod .= "\r\n\t\t\t$" . $mockShortName . ' = $this->' . $mockMethodName .
+ '($mockArgs[\'' . $refMethodName . '\'], $mockTimes[\'' . $refMethodName . '\']);' . "\r\n\t\t\t" .
+ '$mockMethod->andReturn($' . $mockShortName . ');';
+
+ break;
+ }
+ $mockMethod .= "\r\n\t\t}\r\n";
+ $mockMethods[] = $mockMethod;
+ }
+ $mock = '$mock = \Mockery::namedMock(\'Mock\\' . $className . '\', \\' . $className . '::class);';
+ $mock .= "\r\n\r\n\t\t" . implode("\r\n\t\t", $mockMethods) . "\r\n";
+ $mockArgs = '[' . implode(', ', $mockArgs) . ']';
+ $mockTimes = '[' . implode(', ', $mockTimes) . ']';
+
+ return [$mock, $mockArgs, $mockTimes];
+ }
+
+ /**
+ * Return mock method template name.
+ *
+ * @return string
+ */
+ public function getMockMethodTemplate(): string
+ {
+ return self::METHOD_TEMPLATE_MOCKERY;
+ }
+
+ /**
+ * Return mock method return interface.
+ *
+ * @return string
+ */
+ public function getReturnMockInterface(): string
+ {
+ return self::METHOD_RETURN_MOCK_INTERFACE_MOCKERY;
+ }
+
+ /**
+ * Set method names, that should be excluded.
+ *
+ * @param string[] $excludeMethods
+ */
+ public static function setExcludeMethods(array $excludeMethods): void
+ {
+ self::$excludeMethods = $excludeMethods;
+ }
+}
diff --git a/src/Generator/Mock/PhpUnit.php b/src/Generator/Mock/PhpUnit.php
new file mode 100644
index 0000000..8b65f35
--- /dev/null
+++ b/src/Generator/Mock/PhpUnit.php
@@ -0,0 +1,282 @@
+ 2 ||
+ in_array(explode('\\', trim($className, '\\'))[0], AbstractGenerator::EXCLUDE_DECLARING_CLASSES, true)
+ ) {
+ $mock = '$this->getMockBuilder(\'' . $className . '\')
+ ->disableOriginalConstructor()
+ ->getMock();';
+
+ return [$mock, '', ''];
+ }
+
+ [$extendedClasses, $extendedTraits] = $this->getParentClassesAndTraits($reflection);
+ $methods = $reflection->getMethods();
+ $refMethods = [];
+ $mockMethods = [];
+ $mockArgs = [];
+ $mockTimes = [];
+
+ foreach ($methods as $refMethod) {
+ $refMethodName = $refMethod->getName();
+
+ if (
+ $refMethod->isConstructor() ||
+ $refMethod->isDestructor() ||
+ $refMethod->isProtected() ||
+ $refMethod->isPrivate() ||
+ $refMethod->isStatic() ||
+ in_array($className, self::$excludeMethods, true) ||
+ in_array($refMethodName, self::$excludeMethods, true) ||
+ (isset(self::$excludeMethods[$className]) && in_array($refMethodName, self::$excludeMethods[$className], true)) ||
+ (
+ isset(self::$excludeMethods[$className]['all_except']) &&
+ !in_array($refMethodName, self::$excludeMethods[$className]['all_except'], true)
+ )
+ ) {
+ continue;
+ }
+ $refMethodDeclaringClassName = $refMethod->getDeclaringClass()->getName();
+ $mockArgs[] = '\'' . $refMethodName . '\' => \'\'';
+ $mockTimes[] = '\'' . $refMethodName . '\' => 0';
+
+ if (
+ in_array(explode('\\', trim($refMethodDeclaringClassName, '\\'))[0], AbstractGenerator::EXCLUDE_DECLARING_CLASSES_FOR_METHODS) ||
+ in_array(strtolower($refMethodName), AbstractGenerator::EXCLUDE_DECLARING_METHODS)
+ ) {
+ continue;
+ }
+ $returnType = null;
+
+ if (in_array($refMethodName, self::METHODS_RETURN_SELF, true)) {
+ $returnType = $className;
+ } else {
+ $returnType = $refMethod->getReturnType();
+
+ if (null !== $returnType) {
+ $returnType = $returnType->getName();
+ }
+ }
+
+ if (!$returnType) {
+ try {
+ $returnType = $this->getReturnFromAnnotation($refMethod);
+ } catch (ReturnTypeNotFoundException $e) {
+ if ($this->returnTypeNotFoundThrowable) {
+ throw $e;
+ }
+ $returnType = DataTypeInterface::TYPE_MIXED;
+ }
+ }
+
+ switch ($returnType) {
+ case DataTypeInterface::TYPE_INT:
+ case DataTypeInterface::TYPE_INTEGER:
+ $mockMethod = '$' . $mockName .
+ '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue(1));';
+
+ break;
+
+ case DataTypeInterface::TYPE_FLOAT:
+ $mockMethod = '$' . $mockName .
+ '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue(1.5));';
+
+ break;
+
+ case DataTypeInterface::TYPE_MIXED:
+ case DataTypeInterface::TYPE_STRING:
+ $mockMethod = '$' . $mockName .
+ '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue(\'testMock' . $refMethodName . '\'));';
+
+ break;
+
+ case DataTypeInterface::TYPE_BOOL:
+ case DataTypeInterface::TYPE_BOOLEAN:
+ case DataTypeInterface::TYPE_BOOL_TRUE:
+ $mockMethod = '$' . $mockName .
+ '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue(true));';
+
+ break;
+
+ case DataTypeInterface::TYPE_BOOL_FALSE:
+ $mockMethod = '$' . $mockName .
+ '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue(false));';
+
+ break;
+
+ case DataTypeInterface::TYPE_NULL:
+ $mockMethod = '$' . $mockName .
+ '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue(null));';
+
+ break;
+
+ case DataTypeInterface::TYPE_VOID:
+ $mockMethod = '$' . $mockName .
+ '->expects($this->any())->method(\'' . $refMethodName . '\')';
+
+ break;
+
+ case DataTypeInterface::TYPE_ARRAY:
+ case DataTypeInterface::TYPE_ARRAY_MIXED:
+ $mockMethod = '$' . $mockName .
+ '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue([\'test1\', \'test2\', \'test3\']));';
+
+ break;
+
+ case DataTypeInterface::TYPE_CLOSURE:
+ $mockMethod = '$' . $mockName .
+ '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue(function () { return true; }));';
+
+ break;
+
+ case DataTypeInterface::TYPE_SELF:
+ case DataTypeInterface::TYPE_THIS:
+ case DataTypeInterface::TYPE_STATIC:
+ case $className:
+ $mockMethod = '$' . $mockName .
+ '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnSelf());';
+
+ break;
+
+ default:
+ if (
+ in_array($returnType, $extendedClasses, true) ||
+ in_array($returnType, $extendedTraits, true)
+ ) {
+ $mockMethod = '$' . $mockName .
+ '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnSelf());';
+
+ break;
+ }
+ $filename = $reflection->getFileName();
+
+ if (false === $filename) {
+ throw new FileNotExistsException(sprintf('File \'%s\' does not exists.', $reflection->getName()));
+ }
+ $refNamespaces = $this->getNamespacesFromSource($filename);
+ $namespacesFromParentClassesAndTraits = $this->getNamespacesFromParentClassesAndTraits($reflection);
+ $namespaces = array_merge($parentNamespaces, $refNamespaces, $namespacesFromParentClassesAndTraits);
+ $refMockName = $mockGenerator->addMock($returnType, $reflection, $refMethod, $namespaces, $level, $parentClass);
+ $mockStructure = $mockGenerator->getMock($refMockName);
+
+ if (null === $mockStructure) {
+ throw new MockNotExistsException(sprintf('Mock \'%s\' does not exist.', $refMockName));
+ }
+ $mockMethodName = $mockStructure['mockMethodName'];
+ $mockShortName = $mockStructure['mockShortName'];
+ $mockMethod = "\r\n\t\t$" . $mockShortName . ' = $this->' . $mockMethodName . "();\r\n\t\t$" . $mockName .
+ '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue($' . $mockShortName . '));';
+
+ break;
+ }
+ $mockMethods[] = $mockMethod;
+ $refMethods[] = '\'' . $refMethodName . '\'';
+ }
+ $mock = '$this->getMockBuilder(\'' . $className . '\')
+ ->setMethods([' . implode(', ', $refMethods) . '])
+ ->disableOriginalConstructor()
+ ->getMock();';
+
+ $mock .= "\r\n\r\n\t\t" . implode("\r\n\t\t", $mockMethods) . "\r\n";
+ $mockArgs = '[' . implode(', ', $mockArgs) . ']';
+ $mockTimes = '[' . implode(', ', $mockTimes) . ']';
+
+ return [$mock, $mockArgs, $mockTimes];
+ }
+
+ /**
+ * Return mock method template name.
+ *
+ * @return string
+ */
+ public function getMockMethodTemplate(): string
+ {
+ return self::METHOD_TEMPLATE_PHPUNIT;
+ }
+
+ /**
+ * Return mock method return interface.
+ *
+ * @return string
+ */
+ public function getReturnMockInterface(): string
+ {
+ return self::METHOD_RETURN_MOCK_INTERFACE_PHPUNIT;
+ }
+
+ /**
+ * Set method names, that should be excluded.
+ *
+ * @param string[] $excludeMethods
+ */
+ public static function setExcludeMethods(array $excludeMethods): void
+ {
+ self::$excludeMethods = $excludeMethods;
+ }
+}
diff --git a/src/Generator/MockGenerator.php b/src/Generator/MockGenerator.php
new file mode 100644
index 0000000..ba772ca
--- /dev/null
+++ b/src/Generator/MockGenerator.php
@@ -0,0 +1,427 @@
+mockTestPath = $mockTestPath;
+ $this->mockNamespace = $mockNamespace;
+ $this->projectNamespace = $projectNamespace;
+ $this->mockGenerator = $this->makeMockGeneratorByType($mockType);
+ }
+
+ /**
+ * Build and return mock file path.
+ *
+ * @param string $className
+ *
+ * @return string[]
+ */
+ private function getMockPathAndNamespace(string $className): array
+ {
+ $mockFilePath = $this->mockTestPath . DIRECTORY_SEPARATOR;
+ $mockFullClassName = $this->mockNamespace . '\\';
+
+ if (false === strpos($className, $this->projectNamespace)) {
+ $mockFilePath .= self::MOCK_VENDOR_FOLDER . DIRECTORY_SEPARATOR;
+ $mockFullClassName .= self::MOCK_VENDOR_FOLDER . '\\';
+ $tmpNamespace = $className;
+ } else {
+ $tmpNamespace = substr($className, strlen($this->projectNamespace) + 1);
+ }
+
+ if (false === strpos($tmpNamespace, '\\')) {
+ $tmpNamespace = self::MOCK_NATIVE_NAMESPACE . '\\' . $tmpNamespace;
+ }
+ $tmpNamespace = implode('\\', array_slice(explode('\\', $tmpNamespace), 0, 2));
+ $tmpClassName = implode('\\', array_slice(explode('\\', $tmpNamespace), 1, 1));
+ $mockFilePath .= str_replace('\\', DIRECTORY_SEPARATOR, $tmpNamespace) . self::MOCK_TRAIT_SUFFIX . '.php';
+ $mockClassName = $tmpClassName . self::MOCK_TRAIT_SUFFIX;
+ $mockFullClassName .= $tmpNamespace . self::MOCK_TRAIT_SUFFIX;
+ $pos = strrpos($mockFullClassName, '\\');
+
+ if (false === $pos) {
+ $pos = strlen($mockFullClassName);
+ }
+ $mockNamespace = substr($mockFullClassName, 0, $pos);
+
+ return [$mockFilePath, $mockNamespace, $mockClassName, $mockFullClassName];
+ }
+
+ /**
+ * Mock code generator factory.
+ *
+ * @param string $type
+ *
+ * @return MockInterface
+ *
+ * @throws InvalidMockTypeException
+ */
+ private function makeMockGeneratorByType(string $type): MockInterface
+ {
+ switch ($type) {
+ case self::MOCK_TYPE_PHPUNIT:
+ return new PhpUnit();
+
+ case self::MOCK_TYPE_MOCKERY:
+ return new Mockery();
+
+ default:
+ throw new InvalidMockTypeException('Invalid mock type.');
+ }
+ }
+
+ /**
+ * Generate and save test mocks.
+ *
+ * @return string|null
+ *
+ * @throws Exception
+ */
+ public function generate(): ?string
+ {
+ $mocks = [];
+ $mockFiles = [];
+ $mockTraits = [];
+ $mockFileInitialized = [];
+
+ if (!$this->mocks) {
+ return null;
+ }
+
+ foreach ($this->mocks as $mockName => $mock) {
+ [$mockFilepath, $mockNamespace, $mockClassName, $mockFullClassName] = $this->getMockPathAndNamespace($mock['className']);
+
+ if (!isset($mockFileInitialized[$mockFilepath]) && file_exists($mockFilepath)) {
+ $methods = $this->parseMethodsFromSource($mockFilepath);
+
+ foreach ($methods as $method) {
+ $mockFiles[$mockFilepath][] = $method;
+ }
+ $mockFileInitialized[$mockFilepath] = true;
+ }
+
+ if (!isset($mockTraits[$mockFullClassName])) {
+ $mockTraits[$mockFullClassName] = '\\' . $mockFullClassName;
+ }
+
+ if (!isset($mockFiles[$mockFilepath])) {
+ $mockFiles[$mockFilepath] = [];
+ }
+ $mockMethodName = $mock['mockMethodName'];
+
+ if (in_array($mockMethodName, $mockFiles[$mockFilepath], true)) {
+ continue;
+ }
+ $mockTemplate = new Template(
+ sprintf(
+ '%s%stemplate%s%s.tpl',
+ __DIR__,
+ DIRECTORY_SEPARATOR,
+ DIRECTORY_SEPARATOR,
+ $this->mockGenerator->getMockMethodTemplate()
+ )
+ );
+ $mockTemplate->setVar([
+ 'mockClassName' => $mock['className'],
+ 'mockName' => $mockName,
+ 'mockMethodName' => $mockMethodName,
+ 'mockInterface' => $this->mockGenerator->getReturnMockInterface(),
+ 'mock' => $mock['mock'],
+ 'mockArgs' => $mock['mockArgs'],
+ 'mockTimes' => $mock['mockTimes'],
+ ]);
+
+ if (!isset($mocks[$mockFilepath])) {
+ $mocks[$mockFilepath] = [
+ 'className' => $mockClassName,
+ 'fullClassName' => $mockFullClassName,
+ 'namespace' => $mockNamespace,
+ 'methods' => [],
+ ];
+ }
+ $mocks[$mockFilepath]['methods'][] = $mockTemplate->render();
+ }
+
+ foreach ($mocks as $mockFilepath => $mock) {
+ $mockCode = implode('', $mock['methods']);
+
+ if (!file_exists($mockFilepath)) {
+ $mockCode = $this->generateNewMockTrait($mock['namespace'], $mock['className'], $mockCode);
+ }
+ $this->saveFile($mockFilepath, $mockCode);
+ }
+
+ return implode(',', $mockTraits);
+ }
+
+ /**
+ * Generate new mock trait.
+ *
+ * @param string $namespace
+ * @param string $className
+ * @param string $methods
+ *
+ * @return string
+ *
+ * @throws Exception
+ */
+ private function generateNewMockTrait(string $namespace, string $className, string $methods): string
+ {
+ $classTemplate = new Template(
+ sprintf(
+ '%s%stemplate%sTrait.tpl',
+ __DIR__,
+ DIRECTORY_SEPARATOR,
+ DIRECTORY_SEPARATOR
+ )
+ );
+ $classTemplate->setVar([
+ 'namespace' => trim($namespace, '\\'),
+ 'className' => $className,
+ 'methods' => $methods,
+ 'date' => date('Y-m-d'),
+ 'time' => date('H:i:s'),
+ ]);
+
+ return $classTemplate->render();
+ }
+
+ /**
+ * Save code to file, if file exist append code, if no, check if folder exist and create if needed.
+ *
+ * @param string $filepath
+ * @param string $code
+ *
+ * @throws CodeExtractException
+ */
+ private function saveFile(string $filepath, string $code): void
+ {
+ $pathInfo = pathinfo($filepath);
+
+ if (!file_exists($pathInfo['dirname'])) {
+ if (!mkdir($concurrentDirectory = $pathInfo['dirname'], 0755, true) && !is_dir($concurrentDirectory)) {
+ throw new RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory));
+ }
+ }
+
+ if (file_exists($filepath)) {
+ $existCode = file_get_contents($filepath);
+
+ if (false === $existCode) {
+ throw new CodeExtractException(sprintf('Code can not be extract from source \'%s\'.', $filepath));
+ }
+ $pos = strrpos($existCode, "\n}");
+
+ if (false === $pos) {
+ $pos = strlen($existCode);
+ }
+ $existCode = substr($existCode, 0, $pos);
+ $code = $existCode . $code . "\n}";
+ }
+
+ file_put_contents($filepath, $code);
+ }
+
+ /**
+ * Generate mock object.
+ *
+ * @param string|ReflectionClass $className
+ * @param ReflectionClass $parentClass
+ * @param ReflectionMethod $parentMethod
+ * @param mixed[] $parentNamespaces
+ * @param int $level
+ * @param ReflectionClass|null $parentAddMockClass
+ *
+ * @return string
+ *
+ * @throws CodeExtractException
+ * @throws FileNotExistsException
+ */
+ public function addMock(
+ $className,
+ ReflectionClass $parentClass,
+ ReflectionMethod $parentMethod,
+ array $parentNamespaces,
+ int $level = 0,
+ ?ReflectionClass $parentAddMockClass = null
+ ): string {
+ ++$level;
+ $reflection = $className;
+
+ if (!$reflection instanceof ReflectionClass) {
+ $reflection = trim($reflection);
+ $reflection = $this->makeReflectionClass($reflection, $parentNamespaces, $parentMethod, $parentClass);
+ }
+ $className = $reflection->getName();
+ $mockName = ucfirst(str_replace('\\', '', $className . self::MOCK_NAME_PREFIX));
+
+ if (isset($this->mocks[$mockName])) {
+ return $mockName;
+ }
+ $mockShortName = lcfirst(str_replace('\\', '', str_replace($this->projectNamespace, '', $className) . self::MOCK_NAME_PREFIX));
+ $mockMethodName = MockInterface::METHOD_NAME_PREFIX . ucfirst($mockShortName);
+
+ if (null === $parentAddMockClass || $parentAddMockClass->getName() !== $reflection->getName()) {
+ [$mock, $mockArgs, $mockTimes] = $this->mockGenerator->generateMockCode($this, $className, $reflection, $level, $mockName, $parentClass, $parentNamespaces);
+ $this->mocks[$mockName] = ['className' => $className, 'mockShortName' => $mockShortName, 'mockMethodName' => $mockMethodName, 'mock' => $mock, 'mockArgs' => $mockArgs, 'mockTimes' => $mockTimes];
+ } else {
+ $this->mocks[$mockName] = ['className' => $className, 'mockShortName' => $mockShortName, 'mockMethodName' => $mockMethodName, 'mock' => '$mock', 'mockArgs' => '[]', 'mockTimes' => '[]'];
+ }
+
+ return $mockName;
+ }
+
+ /**
+ * Return, if exists, mock structure.
+ *
+ * @param string $mockName
+ *
+ * @return mixed[]|null
+ */
+ public function getMock(string $mockName): ?array
+ {
+ return $this->mocks[$mockName] ?: null;
+ }
+
+ /**
+ * Make and return ReflectionClass.
+ *
+ * @param string $className
+ * @param string[] $parentNamespaces
+ * @param ReflectionMethod $parentMethod
+ * @param ReflectionClass $parentClass
+ *
+ * @return ReflectionClass
+ *
+ * @throws CodeExtractException
+ * @throws FileNotExistsException
+ *
+ * @psalm-suppress ArgumentTypeCoercion
+ */
+ private function makeReflectionClass(
+ string $className,
+ array $parentNamespaces,
+ ReflectionMethod $parentMethod,
+ ReflectionClass $parentClass
+ ): ReflectionClass {
+ try {
+ $reflection = new ReflectionClass($className);
+ } catch (Throwable $e) {
+ $alias = $className;
+ $fullClassName = $className;
+
+ if (strpos('\\', $className)) {
+ $alias = explode('\\', $className)[0];
+ }
+
+ if (isset($parentNamespaces[$alias])) {
+ $fullClassName = $parentNamespaces[$alias];
+ }
+
+ try {
+ $reflection = new ReflectionClass($fullClassName);
+ } catch (Throwable $e) {
+ $fullClassName = $this->getClassNameFromComplexAnnotationName($fullClassName, $parentMethod->getDeclaringClass());
+
+ try {
+ $reflection = new ReflectionClass($fullClassName);
+ } catch (Throwable $e) {
+ throw new RuntimeException(
+ sprintf(
+ 'Class \'%s\' does not exists, creating mock in parent class \'%s\' for method \'%s\'.',
+ $fullClassName,
+ $parentClass->getName(),
+ $parentMethod->getName()
+ )
+ );
+ }
+ }
+ }
+
+ return $reflection;
+ }
+}
diff --git a/src/Generator/Preprocessor/PreprocessorInterface.php b/src/Generator/Preprocessor/PreprocessorInterface.php
new file mode 100644
index 0000000..135e1be
--- /dev/null
+++ b/src/Generator/Preprocessor/PreprocessorInterface.php
@@ -0,0 +1,35 @@
+outClassName = $this->parseFullyQualifiedClassName(
+ $outClassName
+ );
+
+ $this->outSourceFile = str_replace(
+ $this->outClassName['fullyQualifiedClassName'],
+ $this->outClassName['className'],
+ $outSourceFile
+ );
+
+ $this->modules = $modules;
+ $this->sourcePath = $sourcePath;
+ }
+
+ /**
+ * Generate base test class.
+ *
+ * @return string|null
+ *
+ * @throws Exception
+ */
+ public function generate(): ?string
+ {
+ $oathinfo = pathinfo($this->outSourceFile);
+ $classTemplate = new Template(
+ sprintf(
+ '%s%stemplate%s' . $oathinfo['filename'] . '.tpl',
+ __DIR__,
+ DIRECTORY_SEPARATOR,
+ DIRECTORY_SEPARATOR
+ )
+ );
+ $testSuites = $this->getTestsuites($this->modules);
+ $modules = $this->getModules($this->modules);
+
+ $classTemplate->setVar(
+ [
+ 'testsuites' => $testSuites,
+ 'modules' => $modules,
+ 'moduleDir' => $this->sourcePath,
+ 'testClassName' => $this->outClassName['className'],
+ 'date' => date('Y-m-d'),
+ 'time' => date('H:i:s'),
+ ]
+ );
+
+ return $classTemplate->render();
+ }
+
+ /**
+ * Genereate test suites.
+ *
+ * @param mixed[] $modules
+ *
+ * @return string
+ */
+ public function getTestsuites(array $modules): string
+ {
+ $testsuiteModules = [];
+
+ foreach ($modules as $module => $tests) {
+ $testsuite = '';
+ $testsuitefiles = [];
+
+ foreach ($tests as $localTestPath) {
+ $testsuitefiles[] = '' . $localTestPath . '';
+ }
+
+ $testsuite .= "\r\n\t\t\t" . implode("\r\n\t\t\t", $testsuitefiles);
+ $testsuite .= "\r\n\t\t" . '';
+
+ $testsuiteModules[] = $testsuite;
+ }
+
+ return implode("\r\n\t\t", $testsuiteModules);
+ }
+
+ /**
+ * Genereate modules array.
+ *
+ * @param string[] $modules
+ *
+ * @return string
+ */
+ public function getModules(array $modules): string
+ {
+ $testSuiteModules = [];
+
+ foreach (array_keys($modules) as $module) {
+ $testSuiteModules[] = '\'' . $module . '\'';
+ }
+
+ return implode(', ', $testSuiteModules);
+ }
+}
diff --git a/src/Generator/Template.php b/src/Generator/Template.php
new file mode 100644
index 0000000..428092c
--- /dev/null
+++ b/src/Generator/Template.php
@@ -0,0 +1,156 @@
+setFile($file);
+ $this->openDelimiter = $openDelimiter;
+ $this->closeDelimiter = $closeDelimiter;
+ }
+
+ /**
+ * Sets the template file.
+ *
+ * @param string $file
+ *
+ * @throws Exception
+ */
+ public function setFile(string $file): void
+ {
+ $distFile = $file . '.dist';
+
+ if (file_exists($file)) {
+ $template = file_get_contents($file);
+
+ if (false === $template) {
+ throw new CodeExtractException(sprintf('Code can not be extract from source \'%s\'.', $file));
+ }
+ } elseif (file_exists($distFile)) {
+ $template = file_get_contents($distFile);
+
+ if (false === $template) {
+ throw new CodeExtractException(sprintf('Code can not be extract from source \'%s\'.', $distFile));
+ }
+ } else {
+ throw new RuntimeException(
+ 'Template file could not be loaded.'
+ );
+ }
+ $this->template = $template;
+ }
+
+ /**
+ * Sets one or more template variables.
+ *
+ * @param mixed[] $values
+ * @param bool $merge
+ */
+ public function setVar(array $values, bool $merge = true): void
+ {
+ if (!$merge || empty($this->values)) {
+ $this->values = $values;
+ } else {
+ $this->values = array_merge($this->values, $values);
+ }
+ }
+
+ /**
+ * Renders the template and returns the result.
+ *
+ * @return string
+ */
+ public function render(): string
+ {
+ $keys = [];
+
+ foreach (array_keys($this->values) as $key) {
+ $keys[] = $this->openDelimiter . $key . $this->closeDelimiter;
+ }
+
+ return str_replace($keys, $this->values, $this->template);
+ }
+
+ /**
+ * Renders the template and writes the result to a file.
+ *
+ * @param string $target
+ */
+ public function renderTo(string $target): void
+ {
+ $fp = @fopen($target, 'wt');
+
+ if ($fp) {
+ fwrite($fp, $this->render());
+ fclose($fp);
+ } else {
+ $error = error_get_last();
+
+ if (null === $error) {
+ $error = ['message' => sprintf('Error with writing into \'%s\' file.', $target)];
+ }
+
+ $pos = strpos($error['message'], ':');
+
+ if (false !== $pos) {
+ $error['message'] = substr($error['message'], $pos + 2);
+ }
+
+ throw new RuntimeException(sprintf('Could not write to %s: %s', $target, $error['message']));
+ }
+ }
+}
diff --git a/src/Generator/TestGenerator.php b/src/Generator/TestGenerator.php
new file mode 100644
index 0000000..696d5ea
--- /dev/null
+++ b/src/Generator/TestGenerator.php
@@ -0,0 +1,850 @@
+getFileName();
+
+ if (false === $inSourceFile) {
+ $inSourceFile = '';
+ }
+ unset($reflector);
+ } else {
+ $possibleFilenames = [
+ $inClassName . '.php',
+ str_replace(
+ ['_', '\\'],
+ DIRECTORY_SEPARATOR,
+ $inClassName
+ ) . '.php',
+ ];
+
+ if (empty($inSourceFile)) {
+ foreach ($possibleFilenames as $possibleFilename) {
+ if (is_file($possibleFilename)) {
+ $inSourceFile = $possibleFilename;
+ }
+ }
+ }
+
+ if (empty($inSourceFile)) {
+ throw new RuntimeException(
+ sprintf(
+ 'Neither \'%s\' nor \'%s\' could be opened.',
+ $possibleFilenames[0],
+ $possibleFilenames[1]
+ )
+ );
+ }
+
+ if (!is_file($inSourceFile)) {
+ throw new RuntimeException(
+ sprintf(
+ '\'%s\' could not be opened.',
+ $inSourceFile
+ )
+ );
+ }
+ $inSourceFile = realpath($inSourceFile);
+
+ if (!class_exists($inClassName)) {
+ throw new RuntimeException(
+ sprintf(
+ 'Could not find class \'%s\' in \'%s\'.',
+ $inClassName,
+ $inSourceFile
+ )
+ );
+ }
+ }
+
+ if (false === $inSourceFile) {
+ throw new FileNotExistsException(sprintf('Filename can not be empty.'));
+ }
+
+ if (empty($outClassName)) {
+ $outClassName = $inClassName . 'Test';
+ }
+
+ if (empty($outSourceFile)) {
+ $dirname = dirname($inSourceFile);
+ $outClassNameTree = explode('\\', $outClassName);
+ $outSourceFile = $dirname . DIRECTORY_SEPARATOR . end($outClassNameTree) . '.php';
+ }
+
+ $this->baseNamespace = $baseNamespace;
+ $this->baseTestNamespace = $baseTestNamespace;
+ $this->inClassName = $this->parseFullyQualifiedClassName(
+ $inClassName
+ );
+ $this->outClassName = $this->parseFullyQualifiedClassName(
+ $outClassName
+ );
+ $this->inSourceFile = str_replace(
+ $this->inClassName['fullyQualifiedClassName'],
+ $this->inClassName['className'],
+ $inSourceFile
+ );
+ $this->outSourceFile = str_replace(
+ $this->outClassName['fullyQualifiedClassName'],
+ $this->outClassName['className'],
+ $outSourceFile
+ );
+ $this->initDataProviderGenerator($dataProviderTestPath, $dataProviderNamespace, $baseNamespace, $projectNamespace);
+ $this->initMockGenerator($mockTestPath, $mockNamespace, $baseNamespace, $projectNamespace, $mockType);
+ }
+
+ /**
+ * Init DataProviderGenerator.
+ *
+ * @param string $dataProviderTestPath
+ * @param string $dataProviderNamespace
+ * @param string $baseNamespace
+ * @param string $projectNamespace
+ */
+ private function initDataProviderGenerator(
+ string $dataProviderTestPath,
+ string $dataProviderNamespace,
+ string $baseNamespace,
+ string $projectNamespace
+ ): void {
+ $this->dataProviderGenerator = new DataProviderGenerator(
+ $dataProviderTestPath,
+ $dataProviderNamespace,
+ $baseNamespace,
+ $projectNamespace
+ );
+ }
+
+ /**
+ * Init MockGenerator.
+ *
+ * @param string $mockTestPath
+ * @param string $mockNamespace
+ * @param string $baseNamespace
+ * @param string $projectNamespace
+ * @param string $mockType
+ *
+ * @throws InvalidMockTypeException
+ */
+ private function initMockGenerator(
+ string $mockTestPath,
+ string $mockNamespace,
+ string $baseNamespace,
+ string $projectNamespace,
+ string $mockType
+ ): void {
+ $this->mockGenerator = new MockGenerator($mockTestPath, $mockNamespace, $projectNamespace, $mockType);
+ }
+
+ /**
+ * Generate test class code.
+ *
+ * @return string|null
+ *
+ * @throws Exception
+ * @throws ReflectionException
+ * @throws ReturnTypeNotFoundException
+ *
+ * @psalm-suppress ArgumentTypeCoercion
+ */
+ public function generate(): ?string
+ {
+ $reflectionClass = new ReflectionClass(
+ $this->inClassName['fullyQualifiedClassName']
+ );
+
+ if ($reflectionClass->isAbstract()) {
+ sprintf(
+ 'Class \'%s\' is abstract, test reflectionClass will not be generate',
+ $reflectionClass->getName()
+ );
+
+ return null;
+ }
+ $traits = $reflectionClass->getTraitNames();
+
+ if (null !== $traits) {
+ foreach ($traits as $trait) {
+ if (!trait_exists($trait)) {
+ throw new Exception(sprintf('Trait \'%s\' does not exists!', $trait));
+ }
+ }
+ }
+ $methods = '';
+ $constructArguments = '';
+ $constructArgumentsInitialize = '';
+ $reflectionClassMethods = $reflectionClass->getMethods();
+
+ foreach ($reflectionClassMethods as $reflectionMethod) {
+ if (!$reflectionMethod->isConstructor()) {
+ continue;
+ }
+ [$constructArguments, $constructArgumentsInitialize, ] = $this->processMethodDocComment($reflectionClass, $reflectionMethod);
+ }
+
+ foreach ($reflectionClassMethods as $reflectionMethod) {
+ if ($reflectionMethod->isDestructor() || $reflectionMethod->isConstructor()) {
+ continue;
+ }
+ $methodName = $reflectionMethod->getName();
+ $methodDeclaringClassName = $reflectionMethod->getDeclaringClass()->getName();
+
+ if (
+ !$reflectionMethod->isAbstract()
+ && $reflectionMethod->isPublic()
+ && !in_array(strtolower($methodName), self::EXCLUDE_DECLARING_METHODS, true)
+ && !in_array(explode('\\', trim($methodDeclaringClassName, '\\'))[0], self::EXCLUDE_DECLARING_CLASSES_FOR_METHODS, true)
+ ) {
+ $testMethodCode = $this->renderMethod($reflectionClass, $reflectionMethod, $constructArguments, $constructArgumentsInitialize);
+
+ if (!$testMethodCode) {
+ continue;
+ }
+ $methods .= $testMethodCode;
+ }
+ }
+ $useStatement = [];
+ $useStatement[] = "\r\nuse " . ltrim($this->baseTestNamespace, '\\') . '\\' . 'UnitTestCase;';
+ $useStatement[] = "\r\nuse " . ltrim($this->inClassName['fullyQualifiedClassName'], '\\') . ';';
+ $mockTraits = $this->mockGenerator->generate();
+
+ if (null !== $mockTraits) {
+ $mockTraits = explode(',', $mockTraits);
+
+ foreach ($mockTraits as &$mockTrait) {
+ $reflectionMock = new ReflectionClass($mockTrait);
+ $useStatement[] = "\r\nuse " . $reflectionMock->getName() . ';';
+ $mockTrait = $reflectionMock->getShortName();
+ }
+ }
+ $useTraits = $mockTraits ? "\r\n\tuse " . implode(', ', $mockTraits) . ';' : '';
+ $this->dataProviderGenerator->generate();
+ $classTemplate = new Template(
+ sprintf(
+ '%s%stemplate%sTestClass.tpl',
+ __DIR__,
+ DIRECTORY_SEPARATOR,
+ DIRECTORY_SEPARATOR
+ )
+ );
+ sort($useStatement);
+ $classTemplate->setVar(
+ [
+ 'namespace' => trim($this->outClassName['namespace'], '\\'),
+ 'testBaseFullClassName' => trim($this->outClassName['testBaseFullClassName'], '\\'),
+ 'className' => $this->inClassName['className'],
+ 'fullClassName' => $this->inClassName['fullyQualifiedClassName'],
+ 'useStatement' => implode('', $useStatement),
+ 'useTraits' => $useTraits,
+ 'constructArguments' => $constructArguments,
+ 'constructArgumentsInitialize' => $constructArgumentsInitialize,
+ 'testClassName' => $this->outClassName['className'],
+ 'methods' => $methods,
+ 'date' => date('Y-m-d'),
+ 'time' => date('H:i:s'),
+ ]
+ );
+
+ return $classTemplate->render();
+ }
+
+ /**
+ * Render test method.
+ *
+ * @param ReflectionClass $class
+ * @param ReflectionMethod $method
+ * @param string $constructArguments
+ * @param string $constructArgumentsInitialize
+ *
+ * @return string
+ *
+ * @throws CodeExtractException
+ * @throws FileNotExistsException
+ * @throws InvalidClassnameException
+ * @throws MockNotExistsException
+ * @throws ReflectionException
+ * @throws ReturnTypeNotFoundException
+ */
+ protected function renderMethod(
+ ReflectionClass $class,
+ ReflectionMethod $method,
+ string $constructArguments,
+ string $constructArgumentsInitialize
+ ): ?string {
+ $argumentsInitialize = '';
+ $additional = '';
+ $returnType = $this->getMethodReturnType($method);
+ $methodName = $method->getName();
+
+ switch ($returnType) {
+ case DataTypeInterface::TYPE_INT:
+ case DataTypeInterface::TYPE_INTEGER:
+ $assertion = 'Equals';
+ $template = self::METHOD_TEMPLATE_TYPE_DEFAULT;
+ $expected = '$mockArgs[\'' . $methodName . '\']';
+
+ break;
+
+ case DataTypeInterface::TYPE_STRING:
+ $assertion = 'Equals';
+ $template = self::METHOD_TEMPLATE_TYPE_DEFAULT;
+ $expected = '$mockArgs[\'' . $methodName . '\']';
+
+ break;
+
+ case DataTypeInterface::TYPE_BOOL:
+ case DataTypeInterface::TYPE_BOOLEAN:
+ $assertion = 'True';
+ $template = self::METHOD_TEMPLATE_TYPE_BOOL;
+ $expected = DataTypeInterface::TYPE_BOOL_TRUE;
+
+ break;
+
+ case DataTypeInterface::TYPE_VOID:
+ $assertion = false;
+ $template = self::METHOD_TEMPLATE_TYPE_VOID;
+ $expected = null;
+
+ break;
+
+ case DataTypeInterface::TYPE_ARRAY:
+ case DataTypeInterface::TYPE_ARRAY_MIXED:
+ $assertion = 'Equals';
+ $template = self::METHOD_TEMPLATE_TYPE_DEFAULT;
+ $expected = '$mockArgs[\'' . $methodName . '\']';
+
+ break;
+
+ default:
+ $typeIsArray = false;
+
+ if (false !== strpos($returnType, '[]')) {
+ $returnType = $this->getClassNameFromComplexAnnotationName($returnType, $method->getDeclaringClass());
+ $typeIsArray = true;
+ }
+
+ if (!$typeIsArray) {
+ $assertion = 'InstanceOf';
+ $template = self::METHOD_TEMPLATE_TYPE_DEFAULT;
+ $expected = '\\' . $returnType . '::class';
+ } else {
+ $filename = $class->getFileName();
+ $namespaces = $this->getNamespacesFromSource($filename);
+ $mockName = $this->mockGenerator->addMock($returnType, $class, $method, $namespaces);
+ $mockStructure = $this->mockGenerator->getMock($mockName);
+ $assertion = 'Equals';
+ $template = self::METHOD_TEMPLATE_TYPE_DEFAULT;
+ $mockMethodName = $mockStructure['mockMethodName'];
+ $mockShortName = $mockStructure['mockShortName'];
+ $argumentsInitialize .= '$' . $methodName . 's = [];';
+ $argumentsInitialize .= "\n\r\n\t\t" . 'foreach ($mockArgs[\'' . $methodName . '\'] as $i => $' . $mockShortName . ') {';
+ $argumentsInitialize .= "\r\n\t\t\t$" . $methodName . 's[] = $this->' . $mockMethodName .
+ '($' . $mockShortName . ', $mockTimes[\'' . $methodName . '\'][$i]);' . "\r\n\t\t}\r\n\t\t";
+ $expected = '$' . $methodName . 's';
+ }
+ }
+
+ if ($method->isStatic()) {
+ $template .= 'Static';
+ }
+ $methodTemplate = new Template(
+ sprintf(
+ '%s%stemplate%s%s.tpl',
+ __DIR__,
+ DIRECTORY_SEPARATOR,
+ DIRECTORY_SEPARATOR,
+ $template
+ )
+ );
+ $methodName = $this->generateTestMethodName($method, $returnType);
+
+ if (!isset($this->methodNameCounter[$methodName])) {
+ $this->methodNameCounter[$methodName] = 0;
+ }
+ ++$this->methodNameCounter[$methodName];
+
+ if ($this->methodNameCounter[$methodName] > 1) {
+ $methodName .= $this->methodNameCounter[$methodName];
+ }
+
+ if ($this->testPreprocessor) {
+ if (!$this->testPreprocessor->isShouldBeTested($class, $method)) {
+ return null;
+ }
+
+ $this->testPreprocessor->process($class, $method, $methodName, $additional);
+ }
+ [
+ $arguments,
+ $argumentsInitializeFromMethod,
+ $methodComment,
+ $dataProviderClassName,
+ $dataProviderMethodName
+ ] = $this->processMethodDocComment($class, $method);
+ $methodTemplate->setVar(
+ [
+ 'arguments' => $arguments,
+ 'argumentsInitialize' => $argumentsInitialize . $argumentsInitializeFromMethod,
+ 'assertion' => $assertion,
+ 'expected' => $expected,
+ 'additional' => $additional,
+ 'origMethodName' => $method->getName(),
+ 'className' => $this->inClassName['className'],
+ 'fullClassName' => $this->inClassName['fullyQualifiedClassName'],
+ 'methodName' => $methodName,
+ 'methodComment' => $methodComment,
+ 'constructArguments' => $constructArguments,
+ 'constructArgumentsInitialize' => $constructArgumentsInitialize,
+ 'dataProviderClassName' => $dataProviderClassName,
+ 'dataProviderMethodName' => $dataProviderMethodName,
+ ]
+ );
+
+ return $methodTemplate->render();
+ }
+
+ /**
+ * Build test method name.
+ *
+ * @param ReflectionMethod $method
+ * @param string $returnType
+ *
+ * @return string
+ */
+ protected function generateTestMethodName(ReflectionMethod $method, string $returnType): string
+ {
+ if ($returnType === DataTypeInterface::TYPE_ARRAY_MIXED) {
+ $returnType = DataTypeInterface::TYPE_ARRAY;
+ }
+ $origMethodName = $method->getName();
+ $methodName = lcfirst($origMethodName);
+ $pos = strrpos($returnType, '\\');
+ $returnTypeSuffix = $pos ? substr($returnType, ++$pos) : $returnType;
+
+ if (
+ false !== strpos('create', $origMethodName) ||
+ false !== strpos('make', $origMethodName)
+ ) {
+ return $methodName . 'ShouldInitializeAndReturn' . $returnTypeSuffix;
+ }
+
+ if (false !== strpos('Event', $returnType)) {
+ return $methodName . 'ShouldFire' . $returnTypeSuffix;
+ }
+
+ if (
+ false !== strpos('handle', $origMethodName)
+ ) {
+ $args = [];
+
+ foreach ($method->getParameters() as $param) {
+ $args[] = ucfirst($param->getName());
+ }
+ $methodName .= 'ShouldProcess' . implode('', $args);
+
+ if ($returnType === self::METHOD_TEMPLATE_TYPE_VOID) {
+ return $methodName;
+ }
+
+ if (class_exists($returnType) || interface_exists($returnType)) {
+ return $methodName . 'AndReturn' . $returnTypeSuffix;
+ }
+
+ return $methodName . 'AndReturn' . ucfirst($returnType);
+ }
+
+ if (class_exists($returnType) || interface_exists($returnType)) {
+ return $methodName . 'ShouldReturn' . $returnTypeSuffix;
+ }
+
+ return $methodName . 'ShouldReturn' . ucfirst($returnType);
+ }
+
+ /**
+ * Process method doc comment and parse method description, method arguments and code for inititalize all arguments for testing.
+ *
+ * @param ReflectionClass $reflectionClass
+ * @param ReflectionMethod $reflectionMethod
+ *
+ * @return string[]
+ *
+ * @throws CodeExtractException
+ * @throws FileNotExistsException
+ * @throws InvalidClassnameException
+ * @throws MockNotExistsException
+ * @throws ReflectionException
+ * @throws ReturnTypeNotFoundException
+ *
+ * @psalm-suppress PossiblyNullReference
+ */
+ protected function processMethodDocComment(ReflectionClass $reflectionClass, ReflectionMethod $reflectionMethod): array
+ {
+ $excludeConstructor = false;
+ $class = $reflectionMethod->getDeclaringClass();
+ $methodDeclaringClass = $class->getName();
+
+ if (
+ $reflectionMethod->isConstructor() &&
+ in_array(explode('\\', $methodDeclaringClass)[0], self::EXCLUDE_DECLARING_CLASSES, true)
+ ) {
+ $excludeConstructor = true;
+ }
+ $filename = $reflectionClass->getFileName();
+
+ if (false === $filename) {
+ throw new FileNotExistsException(sprintf('File \'%s\' does not exists.', $reflectionClass->getName()));
+ }
+ $namespaces = $this->getNamespacesFromSource($filename);
+ $methodDocComment = $reflectionMethod->getDocComment();
+ $methodComment = 'Execute \'' . $reflectionMethod->getName() . '\' method';
+
+ if (false !== $methodDocComment) {
+ preg_match_all('/\* (.*)$/Um', $methodDocComment, $annotationComment);
+
+ if (isset($annotationComment[1][0])) {
+ $methodComment = trim($annotationComment[1][0], ". \t\n\r\0\x0B");
+ }
+ }
+ $annotationParams = $this->getParamsFromAnnotation($reflectionMethod, false, true);
+ $parameters = $reflectionMethod->getParameters();
+ $argumentsInitialize = [];
+ $arguments = [];
+ $dataProviderMethodName = $this->dataProviderGenerator->addDataProviderMethod($reflectionClass, $reflectionMethod);
+
+ if (null === $annotationParams) {
+ $annotationParams = [];
+ }
+
+ foreach ($parameters as $i => $param) {
+ $this->dataProviderGenerator->addDataProviderArgument($reflectionClass, $param, $dataProviderMethodName);
+ $setEndColon = true;
+ $argumentName = '$' . $param->getName();
+ $argumentInitialize = '$' . $param->getName() . ' = ';
+
+ if (null !== $param->getType()) {
+ $annotationParams[$i] = $param->getType()->getName();
+ } elseif (isset($annotationParams[$i])) {
+ $annotationParam = $this->findAndReturnClassNameFromUseStatement($annotationParams[$i], $reflectionClass);
+
+ if (null !== $annotationParam) {
+ $annotationParams[$i] = $annotationParam;
+ }
+ }
+
+ if ($param->isDefaultValueAvailable()) {
+ $value = $param->getDefaultValue();
+
+ if (
+ (null === $value || false === $value || true === $value) &&
+ (class_exists($annotationParams[$i]) || interface_exists($annotationParams[$i]))
+ ) {
+ $className = $annotationParams[$i];
+ $mockName = $this->mockGenerator->addMock($className, $reflectionClass, $reflectionMethod, $namespaces);
+ $mockStructure = $this->mockGenerator->getMock($mockName);
+
+ if (null === $mockStructure) {
+ throw new MockNotExistsException(sprintf('Mock \'%s\' does not exist.', $mockName));
+ }
+ $pos = strrpos($className, '\\');
+ $dataProviderMockArgName = $pos ? substr($className, ++$pos) : $className;
+ $mockMethodName = $mockStructure['mockMethodName'];
+ $mockShortName = $mockStructure['mockShortName'];
+ $argumentName = '$' . $mockShortName;
+ $argumentInitialize = '$' . $mockShortName . ' = $this->' . $mockMethodName . '($mockArgs[\'' . $dataProviderMockArgName . '\'], $mockTimes[\'' . $dataProviderMockArgName . '\']);';
+ $setEndColon = false;
+ } elseif (null === $value) {
+ $argumentInitialize .= 'null';
+ } elseif (false === $value) {
+ $argumentInitialize .= 'false';
+ } elseif (true === $value) {
+ $argumentInitialize .= 'true';
+ } elseif (is_numeric($value) || is_float($value)) {
+ $argumentInitialize .= $value;
+ } elseif (is_array($value)) {
+ $tmpValue = [];
+
+ foreach ($value as $key => $val) {
+ $key = is_numeric($key) ? $key : '\'' . $key . '\'';
+ $val = is_numeric($val) || is_float($val) ? $val : '\'' . $val . '\'';
+ $tmpValue[] = $key . ' => ' . $val;
+ }
+ $argumentInitialize .= '[' . implode(', ', $tmpValue) . ']';
+ } else {
+ $argumentInitialize .= '\'' . $value . '\'';
+ }
+ } else {
+ if (!isset($annotationParams[$i]) &&
+ in_array(explode('\\', $methodDeclaringClass)[0], self::EXCLUDE_DECLARING_CLASSES_FOR_METHODS, true)
+ ) {
+ $annotationParams[$i] = null;
+ }
+
+ if (!array_key_exists($i, $annotationParams)) {
+ if ('{@inheritdoc}' === $methodComment) {
+ if ($reflectionMethod->getDeclaringClass()->isInterface()) {
+ throw new Exception(sprintf(
+ 'In interface \'%s\' in method \'%s\' for argument \'%s\' annotation not exists.',
+ $reflectionClass->getName(),
+ $reflectionMethod->getName(),
+ $param->getName()
+ ));
+ }
+ $interfaces = $reflectionMethod->getDeclaringClass()->getInterfaces();
+
+ foreach ($interfaces as $interface) {
+ try {
+ $parentMethod = $interface->getMethod($reflectionMethod->getName());
+
+ return $this->processMethodDocComment($interface, $parentMethod);
+ break;
+ } catch (ReflectionException $e) {
+ continue;
+ } catch (RuntimeException $e) {
+ break;
+ }
+ }
+ $parentClass = $reflectionMethod->getDeclaringClass()->getParentClass();
+
+ if ($parentClass) {
+ $parentMethod = $parentClass->getMethod($reflectionMethod->getName());
+
+ return $this->processMethodDocComment($parentClass, $parentMethod);
+ }
+ }
+
+ throw new RuntimeException(
+ sprintf(
+ 'In class \'%s\' in method \'%s\' for argument \'%s\' annotation not exists.',
+ $reflectionClass->getName(),
+ $reflectionMethod->getName(),
+ $param->getName()
+ )
+ );
+ }
+
+ if (null !== $annotationParams[$i]) {
+ $annotationParams[$i] = trim($annotationParams[$i]);
+
+ if (false !== strpos($annotationParams[$i], '|')) {
+ $annotationParams[$i] = explode('|', $annotationParams[$i])[0];
+ }
+ }
+
+ switch ($annotationParams[$i]) {
+ case DataTypeInterface::TYPE_INT:
+ case DataTypeInterface::TYPE_INTEGER:
+ case DataTypeInterface::TYPE_FLOAT:
+ case DataTypeInterface::TYPE_STRING:
+ case DataTypeInterface::TYPE_BOOL:
+ case DataTypeInterface::TYPE_BOOLEAN:
+ case DataTypeInterface::TYPE_MIXED:
+ case DataTypeInterface::TYPE_ARRAY:
+ case DataTypeInterface::TYPE_ARRAY_MIXED:
+ $argumentInitialize .= '$mockArgs[\'' . $param->getName() . '\']';
+ break;
+
+ case DataTypeInterface::TYPE_CLOSURE:
+ $argumentInitialize .= 'function () { return true; }';
+
+ break;
+
+ default:
+ if (
+ false === $excludeConstructor &&
+ empty($annotationParams[$i])
+ ) {
+ throw new RuntimeException(
+ sprintf(
+ 'Could not find param type for param \'%s\' in method \'%s\' in \'%s\'.',
+ $param->getName(),
+ $reflectionMethod->getName(),
+ $reflectionClass->getName()
+ )
+ );
+ }
+
+ if (!$excludeConstructor && $param->getClass()) {
+ $className = $param->getClass() ?: $annotationParams[$i];
+
+ if (null === $className) {
+ throw new InvalidClassnameException(sprintf('Class name could not be null.'));
+ }
+ $classNameTmp = ($className instanceof ReflectionClass) ? $className->getName() : $className;
+ $pos = strrpos($classNameTmp, '\\');
+ $dataProviderMockArgName = $pos ? substr($classNameTmp, ++$pos) : $classNameTmp;
+ $mockName = $this->mockGenerator->addMock($className, $reflectionClass, $reflectionMethod, $namespaces);
+ $mockStructure = $this->mockGenerator->getMock($mockName);
+
+ if (null === $mockStructure) {
+ throw new MockNotExistsException(sprintf('Mock \'%s\' does not exist.', $mockName));
+ }
+ $mockMethodName = $mockStructure['mockMethodName'];
+ $mockShortName = $mockStructure['mockShortName'];
+ $argumentName = '$' . $mockShortName;
+ $argumentInitialize = '$' . $mockShortName . ' = $this->' . $mockMethodName . '($mockArgs[\'' . $dataProviderMockArgName . '\'], $mockTimes[\'' . $dataProviderMockArgName . '\']);';
+ $setEndColon = false;
+ } else {
+ $argumentInitialize .= '$mockArgs[\'' . $param->getName() . '\']';
+ }
+ }
+ }
+
+ if ($setEndColon) {
+ $argumentInitialize .= ';';
+ }
+ $arguments[] = $argumentName;
+ $argumentsInitialize[] = $argumentInitialize;
+ }
+ $arguments = implode(', ', $arguments);
+ $argumentsInitialize = implode("\r\n\t\t", $argumentsInitialize);
+ $dataProviderName = $this->dataProviderGenerator->getDataProviderName($reflectionClass->getName());
+ $dataProviderConfig = $this->dataProviderGenerator->getDataProvider($dataProviderName);
+ $dataProviderClassName = $dataProviderConfig['dataProviderFullClassName'];
+
+ return [$arguments, $argumentsInitialize, $methodComment, $dataProviderClassName, $dataProviderMethodName];
+ }
+
+ /**
+ * Set preprocessor test generator.
+ *
+ * @return PreprocessorInterface
+ */
+ public function getTestPreprocessor(): PreprocessorInterface
+ {
+ return $this->testPreprocessor;
+ }
+
+ /**
+ * Get preprocessor test generator.
+ *
+ * @param PreprocessorInterface $testPreprocessor
+ */
+ public function setTestPreprocessor(PreprocessorInterface $testPreprocessor): void
+ {
+ $this->testPreprocessor = $testPreprocessor;
+ }
+}
diff --git a/src/Generator/template/Class.tpl.dist b/src/Generator/template/Class.tpl.dist
new file mode 100644
index 0000000..ae4a0d0
--- /dev/null
+++ b/src/Generator/template/Class.tpl.dist
@@ -0,0 +1,14 @@
+markTestIncomplete(
+ 'This test has not been implemented yet.'
+ );
+ }
diff --git a/src/Generator/template/Method.tpl.dist b/src/Generator/template/Method.tpl.dist
new file mode 100644
index 0000000..dd3b044
--- /dev/null
+++ b/src/Generator/template/Method.tpl.dist
@@ -0,0 +1,9 @@
+
+ /**
+ * @todo Implement {methodName}().
+ */
+ public function {methodName}()
+ {
+ // Remove the following line when you implement this method.
+ throw new RuntimeException('Not yet implemented.');
+ }
diff --git a/src/Generator/template/MockMethodMockery.tpl.dist b/src/Generator/template/MockMethodMockery.tpl.dist
new file mode 100644
index 0000000..bd1dbff
--- /dev/null
+++ b/src/Generator/template/MockMethodMockery.tpl.dist
@@ -0,0 +1,16 @@
+
+ /**
+ * Create and return mock object for class {mockClassName}
+ *
+ * @param mixed[] $mockArgs
+ * @param mixed[] $mockTimes
+ *
+ * @return \{mockInterface}|\{mockClassName}
+ */
+ protected function {mockMethodName}(
+ array $mockArgs = {mockArgs},
+ array $mockTimes = {mockTimes}
+ ): \{mockInterface} {
+ {mock}
+ return $mock;
+ }
diff --git a/src/Generator/template/MockMethodPhpUnit.tpl.dist b/src/Generator/template/MockMethodPhpUnit.tpl.dist
new file mode 100644
index 0000000..f673959
--- /dev/null
+++ b/src/Generator/template/MockMethodPhpUnit.tpl.dist
@@ -0,0 +1,11 @@
+
+ /**
+ * Create and return mock object for class {mockClassName}
+ *
+ * @return \{mockInterface}|\{mockClassName}
+ */
+ protected function {mockMethodName}()
+ {
+ {mock}
+ return $mock;
+ }
diff --git a/src/Generator/template/TestBaseClass.tpl.dist b/src/Generator/template/TestBaseClass.tpl.dist
new file mode 100644
index 0000000..6b3eeda
--- /dev/null
+++ b/src/Generator/template/TestBaseClass.tpl.dist
@@ -0,0 +1,20 @@
+{origMethodName}({arguments});
+ self::assert{assertion}({expected}, $result);{additional}
+ }
diff --git a/src/Generator/template/TestMethodBool.tpl.dist b/src/Generator/template/TestMethodBool.tpl.dist
new file mode 100644
index 0000000..d037968
--- /dev/null
+++ b/src/Generator/template/TestMethodBool.tpl.dist
@@ -0,0 +1,23 @@
+
+ /**
+ * Test for "{methodComment}".
+ *
+ * @test
+ *
+ * @group unit
+ *
+ * @covers {fullClassName}::{origMethodName}
+ *
+ * @dataProvider {dataProviderClassName}::{dataProviderMethodName}()
+ *
+ * @param mixed[] $mockArgs
+ * @param mixed[] $mockTimes
+ */
+ public function {methodName}Test(array $mockArgs, array $mockTimes): void
+ {
+ {constructArgumentsInitialize}
+ $test = new {className}({constructArguments});
+ {argumentsInitialize}
+ $result = $test->{origMethodName}({arguments});
+ self::assert{assertion}($result);{additional}
+ }
diff --git a/src/Generator/template/TestMethodBoolStatic.tpl.dist b/src/Generator/template/TestMethodBoolStatic.tpl.dist
new file mode 100644
index 0000000..dbf14d5
--- /dev/null
+++ b/src/Generator/template/TestMethodBoolStatic.tpl.dist
@@ -0,0 +1,22 @@
+
+ /**
+ * Test for "{methodComment}".
+ *
+ * @test
+ *
+ * @group unit
+ *
+ * @covers {fullClassName}::{origMethodName}
+ *
+ * @dataProvider {dataProviderClassName}::{dataProviderMethodName}()
+ *
+ * @param mixed[] $mockArgs
+ * @param mixed[] $mockTimes
+ */
+ public function {methodName}Test(array $mockArgs, array $mockTimes): void
+ {
+ {argumentsInitialize}
+ self::assert{assertion}(
+ {className}::{origMethodName}({arguments})
+ );{additional}
+ }
diff --git a/src/Generator/template/TestMethodException.tpl.dist b/src/Generator/template/TestMethodException.tpl.dist
new file mode 100644
index 0000000..6d1cae4
--- /dev/null
+++ b/src/Generator/template/TestMethodException.tpl.dist
@@ -0,0 +1,23 @@
+
+ /**
+ * Test for "{methodComment}".
+ *
+ * @test
+ *
+ * @group unit
+ *
+ * @covers {fullClassName}::{origMethodName}
+ * @expectedException {expected}
+ *
+ * @dataProvider {dataProviderClassName}::{dataProviderMethodName}()
+ *
+ * @param mixed[] $mockArgs
+ * @param mixed[] $mockTimes
+ */
+ public function {methodName}Test(array $mockArgs, array $mockTimes): void
+ {
+ {constructArgumentsInitialize}
+ $test = new {className}({constructArguments});
+ {argumentsInitialize}
+ $test->{origMethodName}({arguments});{additional}
+ }
diff --git a/src/Generator/template/TestMethodExceptionStatic.tpl.dist b/src/Generator/template/TestMethodExceptionStatic.tpl.dist
new file mode 100644
index 0000000..557f911
--- /dev/null
+++ b/src/Generator/template/TestMethodExceptionStatic.tpl.dist
@@ -0,0 +1,21 @@
+
+ /**
+ * Test for "{methodComment}".
+ *
+ * @test
+ *
+ * @group unit
+ *
+ * @covers {fullClassName}::{origMethodName}
+ * @expectedException {expected}
+ *
+ * @dataProvider {dataProviderClassName}::{dataProviderMethodName}()
+ *
+ * @param mixed[] $mockArgs
+ * @param mixed[] $mockTimes
+ */
+ public function {methodName}Test(array $mockArgs, array $mockTimes): void
+ {
+ {argumentsInitialize}
+ {className}::{origMethodName}({arguments});{additional}
+ }
diff --git a/src/Generator/template/TestMethodStatic.tpl.dist b/src/Generator/template/TestMethodStatic.tpl.dist
new file mode 100644
index 0000000..4a79468
--- /dev/null
+++ b/src/Generator/template/TestMethodStatic.tpl.dist
@@ -0,0 +1,21 @@
+
+ /**
+ * Test for "{methodComment}".
+ *
+ * @test
+ *
+ * @group unit
+ *
+ * @covers {fullClassName}::{origMethodName}
+ *
+ * @dataProvider {dataProviderClassName}::{dataProviderMethodName}()
+ *
+ * @param mixed[] $mockArgs
+ * @param mixed[] $mockTimes
+ */
+ public function {methodName}Test(array $mockArgs, array $mockTimes): void
+ {
+ {argumentsInitialize}
+ $test = {className}::{origMethodName}({arguments});
+ self::assert{assertion}({expected}, $test);{additional}
+ }
diff --git a/src/Generator/template/TestMethodVoid.tpl.dist b/src/Generator/template/TestMethodVoid.tpl.dist
new file mode 100644
index 0000000..db55b51
--- /dev/null
+++ b/src/Generator/template/TestMethodVoid.tpl.dist
@@ -0,0 +1,22 @@
+
+ /**
+ * Test for "{methodComment}".
+ *
+ * @test
+ *
+ * @group unit
+ *
+ * @covers {fullClassName}::{origMethodName}
+ *
+ * @dataProvider {dataProviderClassName}::{dataProviderMethodName}()
+ *
+ * @param mixed[] $mockArgs
+ * @param mixed[] $mockTimes
+ */
+ public function {methodName}Test(array $mockArgs, array $mockTimes): void
+ {
+ {constructArgumentsInitialize}
+ $test = new {className}({constructArguments});
+ {argumentsInitialize}
+ $test->{origMethodName}({arguments});{additional}
+ }
diff --git a/src/Generator/template/TestMethodVoidStatic.tpl.dist b/src/Generator/template/TestMethodVoidStatic.tpl.dist
new file mode 100644
index 0000000..6f96078
--- /dev/null
+++ b/src/Generator/template/TestMethodVoidStatic.tpl.dist
@@ -0,0 +1,20 @@
+
+ /**
+ * Test for "{methodComment}".
+ *
+ * @test
+ *
+ * @group unit
+ *
+ * @covers {fullClassName}::{origMethodName}
+ *
+ * @dataProvider {dataProviderClassName}::{dataProviderMethodName}()
+ *
+ * @param mixed[] $mockArgs
+ * @param mixed[] $mockTimes
+ */
+ public function {methodName}Test(array $mockArgs, array $mockTimes): void
+ {
+ {argumentsInitialize}
+ {className}::{origMethodName}({arguments});{additional}
+ }
diff --git a/src/Generator/template/Trait.tpl.dist b/src/Generator/template/Trait.tpl.dist
new file mode 100644
index 0000000..1fc5e4d
--- /dev/null
+++ b/src/Generator/template/Trait.tpl.dist
@@ -0,0 +1,13 @@
+registerNamespaces($modulesNamespaces);
+
+$loader->register();
\ No newline at end of file
diff --git a/src/Generator/template/phpunit.tpl.dist b/src/Generator/template/phpunit.tpl.dist
new file mode 100644
index 0000000..fee1186
--- /dev/null
+++ b/src/Generator/template/phpunit.tpl.dist
@@ -0,0 +1,32 @@
+
+
+
+
+ {testsuites}
+
+
+
+
+ slow
+
+
+
+
+
+ ../vendor
+
+ ../vendor/autoload.php
+
+
+
+
\ No newline at end of file
diff --git a/src/Service/TestClass.php b/src/Service/TestClass.php
new file mode 100644
index 0000000..37beb48
--- /dev/null
+++ b/src/Service/TestClass.php
@@ -0,0 +1,325 @@
+psrNamespaceType = $psrNamespaceType;
+ $this->sourceFolderName = $sourceFolderName;
+ $this->sourceTestFolder = $sourceTestFolder;
+ $this->unitTestFolder = $unitTestFolder;
+ }
+
+ /**
+ * Generate test.
+ *
+ * @param string $className
+ *
+ * @throws Exception
+ */
+ public function generate(string $className): void
+ {
+ if (file_exists($className)) {
+ $this->sourcePath = $className;
+ } elseif (class_exists($className)) {
+ $filename = (new ReflectionClass($className))->getFileName();
+
+ if (false === $filename) {
+ throw new FileNotExistsException(sprintf('File \'%s\' does not exists.', $className));
+ }
+ $this->sourcePath = $filename;
+ } else {
+ throw new RuntimeException(sprintf("Source '%s' doesn't exists!", $className));
+ }
+
+ $item = new SplFileInfo($this->sourcePath);
+ $this->generateTest($item);
+ }
+
+ /**
+ * Generate skeleton for source class.
+ *
+ * @param SplFileInfo $item
+ *
+ * @throws Exception
+ */
+ protected function generateTest(SplFileInfo $item): void
+ {
+ $sourcePath = $item->getPath();
+ $sourceFileName = $item->getFilename();
+ $sourceClassName = $this->getClassName($sourcePath . DIRECTORY_SEPARATOR . $sourceFileName);
+
+ if (!$sourceClassName) {
+ return;
+ }
+ $testPathCredentials = $this->getTestPathFromSource($sourcePath, $sourceClassName['namespace']);
+
+ if (
+ !file_exists($testPathCredentials['testPath']) &&
+ !@mkdir($testPathCredentials['testPath'], 0755, true) &&
+ !is_dir($testPathCredentials['testPath'])
+ ) {
+ throw new Exception(sprintf('Directory \'%s\' was not created!', $testPathCredentials['testPath']));
+ }
+ $generator = new TestGenerator(
+ '\\' . $sourceClassName['namespace'] . '\\' . $sourceClassName['class'],
+ $sourcePath . DIRECTORY_SEPARATOR . $sourceFileName,
+ '\\' . $testPathCredentials['testNamespace'] . '\\' . $sourceClassName['class'] . self::DEFAULT_TEST_PREFIX,
+ $testPathCredentials['testPath'] . DIRECTORY_SEPARATOR . $item->getBasename('.' . $item->getExtension()) . self::DEFAULT_TEST_PREFIX . '.' . $item->getExtension(),
+ $testPathCredentials['baseNamespace'],
+ $testPathCredentials['baseTestNamespace'],
+ MockGenerator::MOCK_TYPE_MOCKERY,
+ $testPathCredentials['dataProviderTestPath'],
+ $testPathCredentials['dataProviderNamespace'],
+ $testPathCredentials['mockTestPath'],
+ $testPathCredentials['mockNamespace'],
+ $testPathCredentials['projectNamespace']
+ );
+
+ $testPreprocessor = $this->getTestPreprocessor($sourceClassName['namespace'] . '\\' . $sourceClassName['class']);
+
+ if ($testPreprocessor) {
+ $generator->setTestPreprocessor($this->getTestPreprocessor($sourceClassName['namespace'] . '\\' . $sourceClassName['class']));
+ }
+ $generator->write();
+ $this->localTestsPath[] = $testPathCredentials['localTestPath'] . DIRECTORY_SEPARATOR . $sourceFileName;
+ }
+
+ /**
+ * Analyze source path and return namespace and origin class name.
+ *
+ * @param string $sourceFilePath
+ *
+ * @return mixed[]
+ *
+ * @throws Exception
+ */
+ protected function getClassName(string $sourceFilePath): array
+ {
+ $namespace = 0;
+ $sourceCode = file_get_contents($sourceFilePath);
+
+ if (false === $sourceCode) {
+ throw new CodeExtractException(sprintf('Code can not be extract from source \'%s\'.', $sourceFilePath));
+ }
+ $tokens = token_get_all($sourceCode);
+ $count = count($tokens);
+ $dlm = false;
+ $class = [];
+
+ for ($i = 2; $i < $count; ++$i) {
+ if ((isset($tokens[$i - 2][1]) && ('phpnamespace' === $tokens[$i - 2][1] || 'namespace' === $tokens[$i - 2][1])) ||
+ ($dlm && T_NS_SEPARATOR === $tokens[$i - 1][0] && T_STRING === $tokens[$i][0])
+ ) {
+ if (!$dlm) {
+ $namespace = 0;
+ }
+
+ if (isset($tokens[$i][1])) {
+ $namespace = $namespace ? $namespace . '\\' . $tokens[$i][1] : $tokens[$i][1];
+ $dlm = true;
+ }
+ } elseif ($dlm && (T_NS_SEPARATOR !== $tokens[$i][0]) && (T_STRING !== $tokens[$i][0])) {
+ $dlm = false;
+ }
+
+ if ((T_CLASS === $tokens[$i - 2][0] || (isset($tokens[$i - 2][1]) && 'phpclass' === $tokens[$i - 2][1]))
+ && T_WHITESPACE === $tokens[$i - 1][0] && T_STRING === $tokens[$i][0]) {
+ $class['namespace'] = $namespace;
+ $class['class'] = $tokens[$i][1];
+ }
+ }
+
+ return $class;
+ }
+
+ /**
+ * Generate test folder for new tests.
+ *
+ * @param string $sourcePath
+ * @param string $namespace
+ *
+ * @return string[]
+ */
+ private function getTestPathFromSource(string $sourcePath, string $namespace): array
+ {
+ $namespace = trim(str_replace('\\', DIRECTORY_SEPARATOR, $namespace), DIRECTORY_SEPARATOR);
+ $sourcePath = trim($sourcePath, DIRECTORY_SEPARATOR);
+ $testFolderPosition = 1;
+
+ switch ($this->psrNamespaceType) {
+ case self::PSR_NAMESPACE_TYPE_1:
+ $sourcePath = str_replace($namespace, '', $sourcePath);
+
+ break;
+
+ case self::PSR_NAMESPACE_TYPE_4:
+ $treePath = array_reverse(explode(DIRECTORY_SEPARATOR, $sourcePath));
+ $treeNamespace = array_reverse(explode(DIRECTORY_SEPARATOR, $namespace));
+ $sourcePath = [];
+ $testFolderPosition = count($treePath) + 1;
+
+ foreach ($treePath as $i => $part) {
+ if ($part === $treeNamespace[$i]) {
+ --$testFolderPosition;
+
+ continue;
+ }
+ array_unshift($sourcePath, $part);
+ }
+ $sourcePath = implode(DIRECTORY_SEPARATOR, $sourcePath);
+
+ break;
+ }
+
+ $treePath = explode(DIRECTORY_SEPARATOR, $sourcePath);
+ $localTestPath = '';
+ $step = 1;
+ $testPath = implode(DIRECTORY_SEPARATOR, array_slice($treePath, 0, count($treePath) - $step));
+ $testPath .= DIRECTORY_SEPARATOR . $this->sourceTestFolder . DIRECTORY_SEPARATOR . $this->unitTestFolder;
+ $dataProviderTestPath = $testPath . DIRECTORY_SEPARATOR . self::DEFAULT_DATA_PROVIDER_NAMESPACE;
+ $mockTestPath = $testPath . DIRECTORY_SEPARATOR . self::DEFAULT_MOCK_NAMESPACE;
+ $namespace = explode(DIRECTORY_SEPARATOR, $namespace);
+ $baseNamespace = implode('\\', array_slice($namespace, $testFolderPosition));
+ $basePath = implode(DIRECTORY_SEPARATOR, array_slice($namespace, $testFolderPosition));
+ $projectNamespace = implode('\\', array_slice($namespace, 0, $testFolderPosition));
+ $dataProviderNamespace = $projectNamespace . '\\' . self::DEFAULT_TEST_NAMESPACE . '\\' . $this->unitTestFolder . '\\' . self::DEFAULT_DATA_PROVIDER_NAMESPACE;
+ $mockNamespace = $projectNamespace . '\\' . self::DEFAULT_TEST_NAMESPACE . '\\' . $this->unitTestFolder . '\\' . self::DEFAULT_MOCK_NAMESPACE;
+ $baseTestNamespace = $projectNamespace . '\\' . self::DEFAULT_TEST_NAMESPACE . '\\' . $this->unitTestFolder;
+ array_splice($namespace, $testFolderPosition, 0, [self::DEFAULT_TEST_NAMESPACE, $this->unitTestFolder]);
+ $testNamespace = implode('\\', $namespace);
+ $localTestPath .= self::DEFAULT_TEST_NAMESPACE . DIRECTORY_SEPARATOR . $this->unitTestFolder;
+ $testPath .= DIRECTORY_SEPARATOR . $basePath;
+ $localTestPath .= DIRECTORY_SEPARATOR . $basePath;
+
+ return [
+ 'testPath' => DIRECTORY_SEPARATOR . $testPath,
+ 'localTestPath' => $localTestPath,
+ 'baseTestNamespace' => $baseTestNamespace,
+ 'testNamespace' => $testNamespace,
+ 'baseNamespace' => $baseNamespace,
+ 'dataProviderTestPath' => DIRECTORY_SEPARATOR . $dataProviderTestPath,
+ 'dataProviderNamespace' => $dataProviderNamespace,
+ 'mockTestPath' => DIRECTORY_SEPARATOR . $mockTestPath,
+ 'mockNamespace' => $mockNamespace,
+ 'projectNamespace' => $projectNamespace,
+ ];
+ }
+
+ /**
+ * Set preprocessor test generator.
+ *
+ * @param string $className
+ *
+ * @return PreprocessorInterface
+ */
+ public function getTestPreprocessor(string $className): ?PreprocessorInterface
+ {
+ return $this->testPreprocessors[$className] ?? null;
+ }
+
+ /**
+ * Get preprocessor test generator.
+ *
+ * @param string $className
+ * @param PreprocessorInterface $testPreprocessor
+ */
+ public function setTestPreprocessor(string $className, PreprocessorInterface $testPreprocessor): void
+ {
+ $this->testPreprocessors[$className] = $testPreprocessor;
+ }
+}
diff --git a/src/Service/TestProject.php b/src/Service/TestProject.php
new file mode 100644
index 0000000..a76171a
--- /dev/null
+++ b/src/Service/TestProject.php
@@ -0,0 +1,135 @@
+sourcePath = $sourcePath;
+ $this->excludeFolders = $excludeFolders;
+ }
+
+ /**
+ * Generate tests.
+ *
+ * @throws Exception
+ */
+ public function generate(): void
+ {
+ if (!file_exists($this->sourcePath)) {
+ throw new Exception("Source file '{$this->sourcePath}' doesn't exists!");
+ }
+ $modules = $this->scanProjectSourceFolder($this->sourcePath, true);
+
+ foreach ($modules as $folder) {
+ foreach ($folder as $file) {
+ $testClass = new TestClass();
+ $testClass->generate($file);
+ }
+ }
+ }
+
+ /**
+ * Scan all subfolders in the source project directory and generate test for all classes.
+ *
+ * @param string $projectSourcePath
+ * @param bool $deepLevel
+ *
+ * @return mixed[]
+ *
+ * @throws Exception
+ */
+ protected function scanProjectSourceFolder(string $projectSourcePath, bool $deepLevel = false): array
+ {
+ $folders = scandir($projectSourcePath);
+
+ if (false === $folders) {
+ throw new NotDirectoryException('Project source path is not a directory.');
+ }
+ $srcFolders = [];
+
+ foreach ($folders as $folder) {
+ if ('.' === $folder || '..' === $folder || in_array($folder, $this->excludeFolders, true)) {
+ continue;
+ }
+ $sourcePath = $projectSourcePath . DIRECTORY_SEPARATOR . $folder;
+
+ if (!is_dir($sourcePath)) {
+ continue;
+ }
+
+ if ($deepLevel) {
+ $subFolders = $this->scanProjectSourceFolder($sourcePath);
+ $srcFolders[$folder] = array_merge(...array_values($subFolders));
+ } else {
+ $srcFolders[$folder] = $this->scanSources($sourcePath);
+ }
+ }
+
+ return $srcFolders;
+ }
+
+ /**
+ * Scan all subfolders in the source project directory and generate test for all classes.
+ *
+ * @param string $sourcePath
+ *
+ * @return mixed[]
+ *
+ * @throws Exception
+ */
+ protected function scanSources(string $sourcePath): array
+ {
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($sourcePath, FilesystemIterator::SKIP_DOTS));
+ $files = [];
+
+ foreach ($iterator as $item) {
+ if (!$item instanceof SplFileInfo) {
+ continue;
+ }
+
+ if ('php' !== $item->getExtension()) {
+ continue;
+ }
+
+ $files[] = $item->getRealPath();
+ }
+
+ return $files;
+ }
+}