diff --git a/.gitattributes b/.gitattributes
index 14e3353..b2b4f67 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -13,7 +13,7 @@ Dockerfile export-ignore
infection.* export-ignore
tests export-ignore
docs export-ignore
-resources/mock export-ignore
+CLAUDE.local.md export-ignore
*.http binary
*.gpg binary
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..f37d822
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "docs/guidelines"]
+ path = docs/guidelines
+ url = https://github.com/php-internal/guidelines
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 0000000..b58eb6d
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,10 @@
+include(__DIR__ . '/src')
+ ->include(__FILE__)
+ ->build();
diff --git a/CLAUDE.local.md b/CLAUDE.local.md
new file mode 100644
index 0000000..9dd4261
--- /dev/null
+++ b/CLAUDE.local.md
@@ -0,0 +1,50 @@
+- Always use guidelines from the `docs/guidelines` folder
+
+## Guidelines Index
+
+### ๐ Documentation Guidelines
+**Path:** `docs/guidelines/how-to-translate-readme-docs.md`
+**Value:** Standardizes documentation translation process and multilingual content management
+**Key Areas:**
+- Translation workflow using LLMs for documentation
+- Multilanguage README pattern using ISO 639-1 codes (`README-{lang_code}.md`)
+- Quality standards: preserve technical content, ensure natural language flow
+- Review process for translated content
+- MAPS framework for complex translations
+
+### ๐ฅ๏ธ Console Command Development
+**Path:** `docs/guidelines/how-to-write-console-command.md`
+**Value:** Ensures consistent CLI interface design and proper Symfony console integration
+**Key Areas:**
+- Command structure: extend `Base` class, use `#[AsCommand]` attribute
+- Required methods: `configure()` and `execute()`
+- Type system: always use `Path` value object instead of strings for file paths
+- Interactive patterns: use `$input->isInteractive()` for detection
+- Error handling: proper return codes (`Command::SUCCESS`, `Command::FAILURE`, `Command::INVALID`)
+- Best practices: method extraction, confirmation dialogs, file operation patterns
+- Available services through DI container (Logger, StyleInterface, etc.)
+
+### ๐ PHP Code Standards
+**Path:** `docs/guidelines/how-to-write-php-code-best-practices.md`
+**Value:** Maintains modern PHP code quality and leverages latest language features for better performance and maintainability
+**Key Areas:**
+- Modern PHP 8.1+ features: constructor promotion, union types, match expressions, throw expressions
+- Code structure: PER-2 standards, single responsibility, final classes by default
+- Enumerations: use enums for fixed value sets, CamelCase naming, backed enums for primitives
+- Immutability: readonly properties, `with` prefix for immutable updates
+- Type system: precise PHPDoc annotations, generics, non-empty-string types
+- Comparison patterns: strict equality (`===`), null coalescing (`??`), avoid `empty()`
+- Dependency injection and IoC container patterns
+
+### ๐งช Testing Guidelines
+**Path:** `docs/guidelines/how-to-write-tests.md`
+**Value:** Ensures comprehensive test coverage with modern PHPUnit practices and proper test isolation
+**Key Areas:**
+- Test structure: mirror source structure, `final` test classes, Arrange-Act-Assert pattern
+- Module testing: independent test areas with dedicated `Stub` directories
+- Naming: `{ClassUnderTest}Test`, descriptive method names
+- Modern PHPUnit: PHP 8.1+ attributes (`#[CoversClass]`, `#[DataProvider]`), data providers with generators
+- Isolation: mock dependencies, use test doubles, reset state between tests
+- **Critical restrictions**: DO NOT mock enums or final classes - use real instances
+- Error testing: expectException before Act phase
+- Test traits for shared functionality
\ No newline at end of file
diff --git a/bin/testo b/bin/testo
new file mode 100644
index 0000000..d28c042
--- /dev/null
+++ b/bin/testo
@@ -0,0 +1,56 @@
+#!/usr/bin/env php
+setCommandLoader(
+ new FactoryCommandLoader([
+ Command\Run::getDefaultName() => static fn() => new Command\Run(),
+ ]),
+ );
+ $application->setDefaultCommand(Command\Run::getDefaultName(), false);
+ $application->setVersion(Info::version());
+ $application->setName(Info::NAME);
+ $application->run();
+})();
diff --git a/bin/testo.bat b/bin/testo.bat
new file mode 100644
index 0000000..223c5cf
--- /dev/null
+++ b/bin/testo.bat
@@ -0,0 +1,10 @@
+@echo off
+@setlocal
+
+set BIN_PATH=%~dp0
+
+if "%PHP_COMMAND%" == "" set PHP_COMMAND=php
+
+"%PHP_COMMAND%" "%BIN_PATH%testo" %*
+
+@endlocal
diff --git a/composer.json b/composer.json
index b1529c7..91d6aeb 100644
--- a/composer.json
+++ b/composer.json
@@ -25,14 +25,15 @@
"react/async": "^3.2 || ^4.3",
"react/promise": "^2.10 || ^3.2",
"symfony/console": "^6.4 || ^7",
+ "symfony/finder": "^6.4 || ^7",
"symfony/process": "^6.4 || ^7",
"webmozart/assert": "^1.11",
"yiisoft/injector": "^1.2"
},
"require-dev": {
"buggregator/trap": "^1.10",
- "infection/infection": "^0.31",
"internal/dload": "^1.6",
+ "phpunit/phpunit": "^10.5",
"spiral/code-style": "^2.2.2",
"vimeo/psalm": "^6.10"
},
@@ -46,7 +47,10 @@
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
- }
+ },
+ "files": [
+ "tests/Fixture/functions.php"
+ ]
},
"bin": [
"bin/testo"
@@ -74,6 +78,7 @@
],
"psalm": "psalm",
"psalm:baseline": "psalm --set-baseline=psalm-baseline.xml",
- "psalm:ci": "psalm --output-format=github --shepherd --show-info=false --stats --threads=4"
+ "psalm:ci": "psalm --output-format=github --shepherd --show-info=false --stats --threads=4",
+ "test": "bin/testo"
}
}
diff --git a/docs/guidelines b/docs/guidelines
new file mode 160000
index 0000000..b822129
--- /dev/null
+++ b/docs/guidelines
@@ -0,0 +1 @@
+Subproject commit b822129c966179f9aee844095463ab14659f7efa
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..21157c1
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,32 @@
+
+
+
+
+ tests/Unit
+
+
+
+
+
+
+
+
+ src
+
+
+
diff --git a/psalm.xml b/psalm.xml
new file mode 100644
index 0000000..f5356e7
--- /dev/null
+++ b/psalm.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
diff --git a/resources/version.json b/resources/version.json
new file mode 100644
index 0000000..1772e6f
--- /dev/null
+++ b/resources/version.json
@@ -0,0 +1,3 @@
+{
+ ".": "1.0.0"
+}
diff --git a/src/Application.php b/src/Application.php
new file mode 100644
index 0000000..cee540e
--- /dev/null
+++ b/src/Application.php
@@ -0,0 +1,49 @@
+withConfig($config->services)
+ ->finish();
+ $container->set($config);
+ return new self($container);
+ }
+
+ public function run($filter = new Filter()): RunResult
+ {
+ $suiteResults = [];
+
+ $suiteProvider = $this->container->get(SuiteProvider::class);
+ $suiteRunner = $this->container->get(SuiteRunner::class);
+
+ # Iterate Test Suites
+ foreach ($suiteProvider->withFilter($filter)->getSuites() as $suite) {
+ $suiteResults[] = $suiteRunner->run($suite, $filter);
+ }
+
+ # Run suites
+ return new RunResult($suiteResults);
+ }
+}
diff --git a/src/Attribute/Interceptable.php b/src/Attribute/Interceptable.php
new file mode 100644
index 0000000..a850683
--- /dev/null
+++ b/src/Attribute/Interceptable.php
@@ -0,0 +1,12 @@
+
+ */
+ public readonly array $suites = [
+ new SuiteConfig(
+ name: 'default',
+ location: new FinderConfig(['tests']),
+ ),
+ ],
+
+ /**
+ * Services bindings configuration.
+ */
+ public readonly ServicesConfig $services = new ServicesConfig(),
+ ) {
+ # Validate suite configs
+ $suites === [] and throw new \InvalidArgumentException('At least one test suite must be defined.');
+ \array_walk(
+ $suites,
+ static fn(mixed $suite) => $suite instanceof SuiteConfig or throw new \InvalidArgumentException(
+ 'Each suite must be an instance of SuiteConfig.',
+ ),
+ );
+ }
+}
diff --git a/src/Config/FinderConfig.php b/src/Config/FinderConfig.php
new file mode 100644
index 0000000..458d10b
--- /dev/null
+++ b/src/Config/FinderConfig.php
@@ -0,0 +1,84 @@
+ $include Include directories or files to the scope
+ * @param iterable $exclude Exclude directories or files from the scope
+ *
+ * @note Glob and regex patterns are not supported
+ */
+ public function __construct(
+ iterable $include = [],
+ iterable $exclude = [],
+ ) {
+ foreach ($include as $dir) {
+ $this->include($dir);
+ }
+
+ foreach ($exclude as $dir) {
+ $this->exclude($dir);
+ }
+ }
+
+ /**
+ * Include directory or file to the scope
+ *
+ * @param non-empty-string|Path $path
+ */
+ public function include(string|Path $path): self
+ {
+ $path = Path::create($path);
+ $path->exists() or throw new \InvalidArgumentException("File or directory not found: $path");
+ $path->isDir() and $this->includeDirs[] = (string) $path->absolute();
+ $path->isFile() and $this->includeFiles[] = (string) $path->absolute();
+ return $this;
+ }
+
+ /**
+ * Exclude a directory or a file from the scope
+ *
+ * @param non-empty-string|Path $path
+ */
+ public function exclude(string|Path $path): self
+ {
+ $path = Path::create($path);
+ $path->exists() or throw new \InvalidArgumentException("File or directory not found: $path");
+ $path->isDir() and $this->excludeDirs[] = (string) $path->absolute();
+ $path->isFile() and $this->excludeFiles[] = (string) $path->absolute();
+ return $this;
+ }
+}
diff --git a/src/Config/ServicesConfig.php b/src/Config/ServicesConfig.php
new file mode 100644
index 0000000..9d2634e
--- /dev/null
+++ b/src/Config/ServicesConfig.php
@@ -0,0 +1,10 @@
+ Names of the test suites to filter by.
+ */
+ public readonly array $testSuites = [],
+ ) {}
+
+ /**
+ * Filter tests by Suite names.
+ *
+ * @param non-empty-string ...$names Names of the test suites to filter by.
+ *
+ * @return self A new instance of Filter with the specified test names.
+ */
+ public function withTestSuites(string ...$names): self
+ {
+ return $this->with('testSuites', \array_unique(\array_merge($this->testSuites, $names)));
+ }
+
+ public function withTestCases($name): self
+ {
+ // TODO
+ return $this;
+ }
+}
diff --git a/src/Dto/Run/RunResult.php b/src/Dto/Run/RunResult.php
new file mode 100644
index 0000000..961e7e3
--- /dev/null
+++ b/src/Dto/Run/RunResult.php
@@ -0,0 +1,29 @@
+
+ */
+final class RunResult implements \IteratorAggregate
+{
+ public function __construct(
+ /**
+ * Test result collection.
+ *
+ * @var iterable
+ */
+ public readonly iterable $results,
+ ) {}
+
+ public function getIterator(): \Traversable
+ {
+ yield from $this->results;
+ }
+}
diff --git a/src/Finder/Finder.php b/src/Finder/Finder.php
new file mode 100644
index 0000000..2891241
--- /dev/null
+++ b/src/Finder/Finder.php
@@ -0,0 +1,79 @@
+
+ */
+final class Finder implements \Countable, \IteratorAggregate
+{
+ private SymfonyFinder $finder;
+
+ /**
+ * @param FinderConfig $config Configuration for finder with absolute paths.
+ */
+ public function __construct(FinderConfig $config)
+ {
+ $this->finder = (new SymfonyFinder());
+ $this->finder->in($config->includeDirs);
+ $this->finder->append($config->includeFiles);
+
+ $config->excludeDirs !== [] || $config->excludeFiles !== [] and $this->finder->filter(
+ static function (\SplFileInfo $file) use ($config): bool {
+ $path = Path::create($file->getRealPath())->absolute();
+
+ # Files in excluded files
+ if ($path->isFile() && \in_array((string) $path, $config->excludeFiles, true)) {
+ return false;
+ }
+
+ # Directories in excluded dirs
+ $target = (string) $path;
+ while (!\in_array($target, $config->includeDirs, true)) {
+ if (\in_array($target, $config->excludeDirs, true)) {
+ return false;
+ }
+
+ $target = \dirname($target);
+ }
+
+ return true;
+ },
+ );
+ }
+
+ public function files(): self
+ {
+ $self = clone $this;
+ $self->finder->files();
+ return $self;
+ }
+
+ public function directories(): self
+ {
+ $self = clone $this;
+ $self->finder->directories();
+ return $self;
+ }
+
+ public function getIterator(): \IteratorAggregate
+ {
+ return $this->finder;
+ }
+
+ public function count(): int
+ {
+ return $this->finder->count();
+ }
+
+ public function __clone(): void
+ {
+ $this->finder = clone $this->finder;
+ }
+}
diff --git a/src/Finder/Path.php b/src/Finder/Path.php
new file mode 100644
index 0000000..2ca2155
--- /dev/null
+++ b/src/Finder/Path.php
@@ -0,0 +1,290 @@
+path;
+
+ foreach ($paths as $path) {
+ if ($path instanceof self) {
+ $path->isAbsolute and throw new \LogicException('Joining an absolute path is not allowed.');
+ $result .= self::DS . $path->path;
+ continue;
+ }
+
+ if ($path === '') {
+ continue;
+ }
+
+ $path = self::normalizePath($path);
+ self::_isAbsolute($path) and throw new \LogicException('Joining an absolute path is not allowed.');
+
+ $result .= self::DS . $path;
+ }
+
+ // We return the raw string, not a normalized path, since it's already normalized
+ return self::create($result);
+ }
+
+ /**
+ * Return the file name (the final path component)
+ */
+ public function name(): string
+ {
+ $pos = \strrpos($this->path, self::DS);
+ return $pos === false
+ ? $this->path
+ : \substr($this->path, $pos + 1);
+ }
+
+ /**
+ * Return the file stem (the file name without its extension)
+ *
+ * @return non-empty-string
+ */
+ public function stem(): string
+ {
+ $name = $this->name();
+ $pos = \strrpos($name, '.');
+ return $pos === false || $pos === 0 ? $name : \substr($name, 0, $pos);
+ }
+
+ /**
+ * Return the file suffix (extension) without the leading dot
+ */
+ public function extension(): string
+ {
+ $name = $this->name();
+
+ return \pathinfo($name, PATHINFO_EXTENSION);
+ }
+
+ /**
+ * Return the parent directory path
+ */
+ public function parent(): self
+ {
+ $parts = \explode(self::DS, $this->path);
+
+ if (\count($parts) === 1) {
+ return match ($this->path) {
+ '.' => self::create('..'),
+ '..' => self::create('../..'),
+ default => self::create('.'),
+ };
+ }
+
+ if ($this->isAbsolute && \count($parts) === 2) {
+ // If the path is absolute and has only two parts, return the root
+ return self::create($parts[0] . self::DS);
+ }
+
+ if (!$this->isAbsolute && $parts[\array_key_last($parts)] === '..') {
+ return $this->join('..');
+ }
+
+ // Remove the last part of the path
+ \array_pop($parts);
+ return self::create(\implode(self::DS, $parts));
+ }
+
+ /**
+ * Return whether this path is absolute
+ */
+ public function isAbsolute(): bool
+ {
+ return $this->isAbsolute;
+ }
+
+ /**
+ * Return whether this path is relative
+ */
+ public function isRelative(): bool
+ {
+ return !$this->isAbsolute;
+ }
+
+ /**
+ * Check if the path exists.
+ */
+ public function exists(): bool
+ {
+ return \file_exists($this->path);
+ }
+
+ /**
+ * Check if the path is a directory.
+ * True as the result doesn't mean that the directory exists.
+ */
+ public function isDir(): bool
+ {
+ return match (true) {
+ $this->path === '.',
+ $this->path === '..',
+ $this->isAbsolute && \substr($this->path, -2) === self::DS . '.',
+ \is_dir($this->path) => true,
+ default => false,
+ };
+ }
+
+ /**
+ * @return bool True if the path exists and is writable.
+ */
+ public function isWriteable(): bool
+ {
+ return $this->exists() && \is_writable($this->path);
+ }
+
+ /**
+ * Check if the path is a file.
+ * True as the result doesn't mean that the file exists.
+ */
+ public function isFile(): bool
+ {
+ return match (true) {
+ $this->path === '.',
+ $this->path === '..',
+ $this->isAbsolute && \substr($this->path, -2) === self::DS . '.' => false,
+ \is_file($this->path) => true,
+ default => false,
+ };
+ }
+
+ /**
+ * Return a normalized absolute version of this path
+ */
+ public function absolute(): self
+ {
+ if ($this->isAbsolute()) {
+ return $this;
+ }
+
+ $cwd = \getcwd();
+ $cwd === false and throw new \RuntimeException('Cannot get current working directory.');
+ return self::create($cwd . self::DS . $this->path);
+ }
+
+ /**
+ * Return a normalized relative version of this path.
+ *
+ * @return non-empty-string
+ */
+ public function __toString(): string
+ {
+ return $this->path;
+ }
+
+ /**
+ * Check if a path is absolute.
+ *
+ * @param non-empty-string $path A normalized path.
+ */
+ private static function _isAbsolute(string $path): bool
+ {
+ return \preg_match('~^[a-zA-Z]:~', $path) === 1 || \str_starts_with($path, self::DS);
+ }
+
+ /**
+ * Normalize a path by converting directory separators and resolving special path segments.
+ *
+ * @return non-empty-string
+ */
+ private static function normalizePath(string $path): string
+ {
+ // Normalize directory separators
+ $path = \trim(\str_replace(['\\', '/'], self::DS, $path));
+
+ // Normalize multiple separators
+ $path = (string) \preg_replace(
+ '~' . \preg_quote(self::DS, '~') . '{2,}~',
+ self::DS,
+ $path,
+ );
+
+ // Empty path becomes current directory
+ if ($path === '') {
+ return '.';
+ }
+
+
+ // Determine if the path is absolute
+ $isAbsolute = self::_isAbsolute($path);
+
+ // Resolve special path segments
+ $parts = \explode(self::DS, $path);
+ /** @var non-empty-string|null $driverLetter */
+
+ if ($isAbsolute && \preg_match('~^([a-zA-Z]):~', $path, $matches) === 1) {
+ // Windows-style path with a drive letter
+ $driverLetter = $matches[1];
+ \array_shift($parts);
+ } else {
+ $driverLetter = null;
+ }
+
+ $result = [];
+ foreach ($parts as $part) {
+ $part = \trim($part, ' ');
+ if ($part === '.' || $part === '') {
+ continue;
+ }
+
+ if ($part === '..') {
+ if ($result !== [] && $result[\array_key_last($result)] !== '..') {
+ \array_pop($result);
+ continue;
+ }
+
+ $isAbsolute and throw new \LogicException("Cannot go up from root in `{$path}`");
+ $result[] = '..';
+ continue;
+ }
+
+ $result[] = $part;
+ }
+
+ // Reconstruct the path
+ $normalizedPath = $result === [] ? '.' : \implode(self::DS, $result);
+
+ // Add an absolute path prefix if necessary
+ if ($isAbsolute) {
+ $normalizedPath = $driverLetter !== null
+ ? "$driverLetter:" . self::DS . $normalizedPath
+ : self::DS . $normalizedPath;
+ }
+
+ return $normalizedPath;
+ }
+}
diff --git a/src/Interceptor/CaseLocatorInterceptor.php b/src/Interceptor/CaseLocatorInterceptor.php
new file mode 100644
index 0000000..ddcf78c
--- /dev/null
+++ b/src/Interceptor/CaseLocatorInterceptor.php
@@ -0,0 +1,27 @@
+
+ */
+interface CaseLocatorInterceptor extends InterceptorMarker
+{
+ /**
+ * Locate test cases in the given file.
+ *
+ * Class and function reflections are available there.
+ *
+ * @param FileDefinitions $file File to locate test cases in.
+ * @param callable(FileDefinitions): CaseDefinitions $next Next interceptor or core logic to locate test cases.
+ */
+ public function locateTestCases(FileDefinitions $file, callable $next): CaseDefinitions;
+}
diff --git a/src/Interceptor/Exception/TestCaseInstantiationException.php b/src/Interceptor/Exception/TestCaseInstantiationException.php
new file mode 100644
index 0000000..4ce3573
--- /dev/null
+++ b/src/Interceptor/Exception/TestCaseInstantiationException.php
@@ -0,0 +1,10 @@
+injector = $injector->withCacheReflections(true);
+ }
+
+ /**
+ * Creates an instance of the given class with the given arguments.
+ *
+ * @template T of InterceptorMarker
+ *
+ * @param class-string $class The class to create.
+ * @param array $arguments The arguments to pass to the constructor.
+ *
+ * @return T The created instance.
+ */
+ public function make(string $class, array $arguments = []): InterceptorMarker
+ {
+ return $this->injector->make($class, $arguments);
+ }
+}
diff --git a/src/Interceptor/FileLocatorInterceptor.php b/src/Interceptor/FileLocatorInterceptor.php
new file mode 100644
index 0000000..e5d2b68
--- /dev/null
+++ b/src/Interceptor/FileLocatorInterceptor.php
@@ -0,0 +1,31 @@
+
+ */
+interface FileLocatorInterceptor extends InterceptorMarker
+{
+ /**
+ * Return true if the file might be interesting as a test file.
+ *
+ * The file is not loaded yet, so the interceptor should not try to use reflection on it.
+ * Try to use only the file path, class name, doc comments, function names,
+ * or other parsed tokens to determine if the file is interesting or dangerous to load.
+ *
+ * @param TokenizedFile $file Information about the test to be run.
+ * @param callable(TokenizedFile): (null|bool) $next Next interceptor or core logic
+ * to determine possible test file.
+ * @return null|bool True if the file might be interesting as a test file,
+ * false if dangerous to load, null to other interceptors.
+ */
+ public function locateFile(TokenizedFile $file, callable $next): ?bool;
+}
diff --git a/src/Interceptor/InterceptorProvider.php b/src/Interceptor/InterceptorProvider.php
new file mode 100644
index 0000000..d753e10
--- /dev/null
+++ b/src/Interceptor/InterceptorProvider.php
@@ -0,0 +1,93 @@
+, class-string>
+ */
+ private array $map = [];
+
+ public function __construct(
+ private readonly Factory $factory = new Factory(),
+ ) {}
+
+ public static function createDefault(): self
+ {
+ $self = new self();
+ $self->map = [
+ RetryPolicy::class => RetryPolicyCallInterceptor::class,
+ ];
+ return $self;
+ }
+
+ /**
+ * Get interceptors for
+ *
+ * @template-covariant T of InterceptorMarker
+ *
+ * @param class-string $class The target interceptor class.
+ * @param class-string|InterceptorMarker ...$interceptors Interceptor classes or instances
+ * to filter by the given class.
+ *
+ * @return list Interceptor instances of the given class.
+ */
+ public function fromClasses(string $class, string|InterceptorMarker ...$interceptors): array
+ {
+ return [];
+ }
+
+ /**
+ * Get interceptors for the given attributes set filtered by the given class.
+ *
+ * @template-covariant T of InterceptorMarker
+ *
+ * @param class-string $class The target interceptor class.
+ * @param Interceptable ...$attributes Attributes to get interceptors for.
+ *
+ * @return list Interceptors for the given attributes.
+ */
+ public function fromAttributes(string $class, Interceptable ...$attributes): array
+ {
+ $result = [];
+
+ foreach ($attributes as $attribute) {
+ # Get alias interceptor
+ $iClass = $this->resolveAlias($attribute::class) ?? throw new \RuntimeException(
+ \sprintf('No interceptor found for attribute %s.', $attribute::class),
+ );
+
+ \is_a($iClass, $class, true) and $result[] = $this->factory->make($iClass, [$attribute]);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Resolve alias interceptor for the given attribute class.
+ *
+ * @param class-string $class The attribute class.
+ * @return class-string|null The interceptor class or null if not found.
+ */
+ private function resolveAlias(string $class): ?string
+ {
+ do {
+ if (\array_key_exists($class, $this->map)) {
+ return $this->map[$class];
+ }
+
+ $class = \get_parent_class($class);
+ } while ($class);
+
+ return null;
+ }
+}
diff --git a/src/Interceptor/Internal/InterceptorMarker.php b/src/Interceptor/Internal/InterceptorMarker.php
new file mode 100644
index 0000000..2224400
--- /dev/null
+++ b/src/Interceptor/Internal/InterceptorMarker.php
@@ -0,0 +1,16 @@
+ */
+ private array $interceptors = [];
+
+ /** @var int<0, max> Current interceptor key */
+ private int $current = 0;
+
+ /**
+ * @param array $interceptors
+ */
+ private function __construct(
+ array $interceptors,
+ ) {
+ // Reset keys
+ $this->interceptors = \array_values($interceptors);
+ }
+
+ /**
+ * Make sure that interceptors implement the same interface.
+ * @template-covariant TInt of TInterceptor
+ * @template TIn
+ * @template-covariant TOut
+ * @param TInt ...$interceptors
+ * @return self
+ */
+ public static function prepare(TInterceptor ...$interceptors): self
+ {
+ return new self($interceptors);
+ }
+
+ /**
+ * @param non-empty-string $method Method name of the all interceptors.
+ *
+ * @return callable(object): TOutput
+ */
+ public function with(\Closure $last, string $method): callable
+ {
+ $new = clone $this;
+
+ $new->last = $last;
+ $new->method = $method;
+
+ return $new;
+ }
+
+ /**
+ * Must be used after {@see self::with()} method.
+ *
+ * @param TInput $input Input value for the first interceptor.
+ *
+ * @return TOutput
+ */
+ public function __invoke(object $input): mixed
+ {
+ $interceptor = $this->interceptors[$this->current] ?? null;
+
+ if ($interceptor === null) {
+ return ($this->last)($input);
+ }
+
+ $next = $this->next();
+
+ return $interceptor->{$this->method}($input, $next);
+ }
+
+ private function next(): self
+ {
+ $new = clone $this;
+ ++$new->current;
+
+ return $new;
+ }
+}
diff --git a/src/Interceptor/Locator/FilePostfixTestLocatorInterceptor.php b/src/Interceptor/Locator/FilePostfixTestLocatorInterceptor.php
new file mode 100644
index 0000000..2fd5709
--- /dev/null
+++ b/src/Interceptor/Locator/FilePostfixTestLocatorInterceptor.php
@@ -0,0 +1,45 @@
+path->stem(), 'Test') ? true : $next($file);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function locateTestCases(FileDefinitions $file, callable $next): CaseDefinitions
+ {
+ foreach ($file->classes as $class) {
+ if (!$class->isAbstract() && \str_ends_with($class->getName(), 'Test')) {
+ $case = $file->cases->define($class);
+ foreach ($class->getMethods() as $method) {
+ if ($method->isPublic() && \str_starts_with($method->getName(), 'test')) {
+ $case->tests->define($method);
+ }
+ }
+ }
+ }
+
+ return $next($file);
+ }
+}
diff --git a/src/Interceptor/Locator/SecureLocatorInterceptor.php b/src/Interceptor/Locator/SecureLocatorInterceptor.php
new file mode 100644
index 0000000..a69ecf4
--- /dev/null
+++ b/src/Interceptor/Locator/SecureLocatorInterceptor.php
@@ -0,0 +1,20 @@
+hasIncludes ? false : $next($file);
+ }
+}
diff --git a/src/Interceptor/Locator/TestoAttributesLocatorInterceptor.php b/src/Interceptor/Locator/TestoAttributesLocatorInterceptor.php
new file mode 100644
index 0000000..159e81c
--- /dev/null
+++ b/src/Interceptor/Locator/TestoAttributesLocatorInterceptor.php
@@ -0,0 +1,49 @@
+getClasses() !== [] || $file->getFunctions() !== []) ? true : $next($file);
+ }
+
+ #[\Override]
+ public function locateTestCases(FileDefinitions $file, callable $next): CaseDefinitions
+ {
+ foreach ($file->classes as $class) {
+ if ($class->isAbstract()) {
+ continue;
+ }
+
+ foreach ($class->getMethods() as $method) {
+ if ($method->isPublic() && Reflection::fetchFunctionAttributes($method, attributeClass: Test::class)) {
+ $file->cases->define($class)->tests->define($method);
+ }
+ }
+ }
+
+ foreach ($file->functions as $function) {
+ if ($function->isPublic() && Reflection::fetchFunctionAttributes($function, attributeClass: Test::class)) {
+ $file->cases->define(null)->tests->define($function);
+ }
+ }
+
+ return $next($file);
+ }
+}
diff --git a/src/Interceptor/TestCallInterceptor.php b/src/Interceptor/TestCallInterceptor.php
new file mode 100644
index 0000000..41c4c78
--- /dev/null
+++ b/src/Interceptor/TestCallInterceptor.php
@@ -0,0 +1,23 @@
+
+ */
+interface TestCallInterceptor extends InterceptorMarker
+{
+ /**
+ * @param TestInfo $info Information about the test to be run.
+ * @param callable(TestInfo): TestResult $next Next interceptor or core logic to run the test.
+ */
+ public function runTest(TestInfo $info, callable $next): TestResult;
+}
diff --git a/src/Interceptor/TestCallInterceptor/RetryPolicyCallInterceptor.php b/src/Interceptor/TestCallInterceptor/RetryPolicyCallInterceptor.php
new file mode 100644
index 0000000..53c9772
--- /dev/null
+++ b/src/Interceptor/TestCallInterceptor/RetryPolicyCallInterceptor.php
@@ -0,0 +1,46 @@
+options->maxAttempts;
+ $isFlaky = false;
+
+ run:
+ --$attempts;
+ try {
+ $result = $next($info);
+ return $isFlaky && $this->options->markFlaky
+ ? $result->with(status: Status::Flaky)
+ : $result;
+ } catch (\Throwable $e) {
+ # No more attempts left, rethrow the exception
+ $attempts > 0 or throw $e;
+
+ $isFlaky = true;
+ unset($e);
+ goto run;
+ }
+ }
+}
diff --git a/src/Interceptor/TestCaseCallInterceptor.php b/src/Interceptor/TestCaseCallInterceptor.php
new file mode 100644
index 0000000..f37543e
--- /dev/null
+++ b/src/Interceptor/TestCaseCallInterceptor.php
@@ -0,0 +1,25 @@
+
+ */
+interface TestCaseCallInterceptor extends InterceptorMarker
+{
+ /**
+ * @param CaseInfo $info Test case to run.
+ * @param callable(CaseInfo): CaseResult $next Next interceptor or core logic to run the test case.
+ */
+ public function runTestCase(CaseInfo $info, callable $next): CaseResult;
+}
diff --git a/src/Interceptor/TestCaseCallInterceptor/InstantiateTestCase.php b/src/Interceptor/TestCaseCallInterceptor/InstantiateTestCase.php
new file mode 100644
index 0000000..a6ec687
--- /dev/null
+++ b/src/Interceptor/TestCaseCallInterceptor/InstantiateTestCase.php
@@ -0,0 +1,32 @@
+instance === null && $info->definition->reflection !== null) {
+ // TODO autowire dependencies
+ try {
+ $instance = $info->definition->reflection->newInstance();
+ } catch (\Throwable $e) {
+ throw new TestCaseInstantiationException(previous: $e);
+ }
+
+ $info = $info->withInstance($instance);
+ }
+
+ return $next($info);
+ }
+}
diff --git a/src/Internal/Bootstrap.php b/src/Internal/Bootstrap.php
new file mode 100644
index 0000000..f8f8026
--- /dev/null
+++ b/src/Internal/Bootstrap.php
@@ -0,0 +1,62 @@
+container;
+ unset($this->container);
+
+ return $c;
+ }
+
+ /**
+ * Configures the container with the provided application services configuration.
+ *
+ * Registers core services and bindings.
+ */
+ public function withConfig(
+ ServicesConfig $config,
+ ): self {
+ foreach ($config as $id => $service) {
+ $this->container->bind($id, $service);
+ }
+
+ return $this;
+ }
+}
diff --git a/src/Internal/CloneWith.php b/src/Internal/CloneWith.php
new file mode 100644
index 0000000..3c6c38e
--- /dev/null
+++ b/src/Internal/CloneWith.php
@@ -0,0 +1,33 @@
+newInstanceWithoutConstructor();
+ $new->{$key} = $value;
+ /** @psalm-suppress RawObjectIteration */
+ foreach ($this as $k => $v) {
+ if ($k === $key) {
+ continue;
+ }
+
+ $new->{$k} = $v;
+ }
+ return $new;
+ }
+}
diff --git a/src/Internal/Command/Base.php b/src/Internal/Command/Base.php
new file mode 100644
index 0000000..9fd419e
--- /dev/null
+++ b/src/Internal/Command/Base.php
@@ -0,0 +1,94 @@
+addOption('config', null, InputOption::VALUE_OPTIONAL, 'Path to the configuration file');
+ }
+
+ /**
+ * Initializes the command execution environment.
+ *
+ * Sets up logger, container, and registers input/output in the container.
+ *
+ * @param InputInterface $input Command input
+ * @param OutputInterface $output Command output
+ *
+ * @return int Command success code
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $cfg = new ApplicationConfig(
+ src: new FinderConfig(['src']),
+ suites: [
+ new SuiteConfig(
+ name: 'default',
+ location: new FinderConfig(['tests/Testo']),
+ ),
+ ],
+ );
+
+ $this->application = Application::create($cfg);
+ $this->container = $this->application->container;
+
+ $this->container->set($input, InputInterface::class);
+ $this->container->set($output, OutputInterface::class);
+ $this->container->set(new SymfonyStyle($input, $output), StyleInterface::class);
+
+ return $this->container->get(Injector::class)->invoke($this) ?? Command::SUCCESS;
+ }
+}
diff --git a/src/Internal/Command/Run.php b/src/Internal/Command/Run.php
new file mode 100644
index 0000000..f5fe43a
--- /dev/null
+++ b/src/Internal/Command/Run.php
@@ -0,0 +1,24 @@
+application->run();
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Internal/Container.php b/src/Internal/Container.php
new file mode 100644
index 0000000..f240a60
--- /dev/null
+++ b/src/Internal/Container.php
@@ -0,0 +1,84 @@
+get(Downloader::class);
+ *
+ * // Binding a factory for service creation
+ * $container->bind(Logger::class, function (Container $c) {
+ * return new Logger($c->get(OutputInterface::class));
+ * });
+ * ```
+ *
+ * @internal
+ */
+interface Container extends \Internal\Destroy\Destroyable, \Psr\Container\ContainerInterface
+{
+ /**
+ * Retrieves a service from the container.
+ *
+ * If the service is requested for the first time, it will be instantiated and persisted for future requests.
+ *
+ * @template T
+ * @param class-string $id Service identifier
+ * @param array $arguments Constructor arguments used only on first instantiation
+ * @return T The requested service instance
+ *
+ * @psalm-suppress MoreSpecificImplementedParamType, InvalidReturnType
+ */
+ public function get(string $id, array $arguments = []): object;
+
+ /**
+ * Checks if the service is registered in the container.
+ *
+ * It means that the container has a cached service instance or a binding.
+ *
+ * @param class-string $id Service identifier
+ * @return bool Whether the service is available
+ *
+ * @psalm-suppress MoreSpecificImplementedParamType
+ */
+ public function has(string $id): bool;
+
+ /**
+ * Registers an existing service instance in the container.
+ *
+ * @template T
+ * @param T $service Service instance to register
+ * @param class-string|null $id Optional service identifier (defaults to object's class)
+ */
+ public function set(object $service, ?string $id = null): void;
+
+ /**
+ * Creates a new instance without storing it in the container.
+ *
+ * @template T
+ * @param class-string $class Class to instantiate
+ * @param array $arguments Constructor arguments
+ * @return T Newly created instance
+ */
+ public function make(string $class, array $arguments = []): object;
+
+ /**
+ * Declares a factory or predefined arguments for the specified class.
+ *
+ * Configures how a service should be instantiated.
+ *
+ * @template T
+ * @param class-string $id Service identifier
+ * @param null|class-string|array|\Closure(Container): T $binding Factory
+ * function, constructor arguments, or alias class name.
+ */
+ public function bind(string $id, \Closure|string|array|null $binding = null): void;
+}
diff --git a/src/Internal/Info.php b/src/Internal/Info.php
new file mode 100644
index 0000000..06c9d3b
--- /dev/null
+++ b/src/Internal/Info.php
@@ -0,0 +1,55 @@
+ */
+ private array $cache = [];
+
+ /** @var array */
+ private array $factory = [];
+
+ private readonly Injector $injector;
+
+ /**
+ * @psalm-suppress PropertyTypeCoercion
+ */
+ public function __construct()
+ {
+ $this->injector = (new Injector($this))->withCacheReflections(false);
+ $this->cache[Injector::class] = $this->injector;
+ $this->cache[self::class] = $this;
+ $this->cache[ObjectContainer::class] = $this;
+ $this->cache[ContainerInterface::class] = $this;
+ }
+
+ public function get(string $id, array $arguments = []): object
+ {
+ /** @psalm-suppress InvalidReturnStatement */
+ return $this->cache[$id] ??= $this->make($id, $arguments);
+ }
+
+ public function has(string $id): bool
+ {
+ return \array_key_exists($id, $this->cache) || \array_key_exists($id, $this->factory);
+ }
+
+ public function set(object $service, ?string $id = null): void
+ {
+ \assert($id === null || $service instanceof $id, "Service must be instance of {$id}.");
+ $this->cache[$id ?? \get_class($service)] = $service;
+ }
+
+ public function make(string $class, array $arguments = []): object
+ {
+ $binding = $this->factory[$class] ?? null;
+
+ if ($binding instanceof \Closure) {
+ $result = $this->injector->invoke($binding);
+ } else {
+ try {
+ $result = $this->injector->make($class, \array_merge((array) $binding, $arguments));
+ } catch (\Throwable $e) {
+ throw new class("Unable to create object of class $class.", previous: $e) extends \RuntimeException implements NotFoundExceptionInterface {};
+ }
+ }
+
+ \assert($result instanceof $class, "Created object must be instance of {$class}.");
+
+ // Detect related types
+ // Configs
+ // if (\str_starts_with($class, 'Internal\\Module\\Config\\Schema\\')) {
+ // // Hydrate config
+ // /** @var ConfigLoader $configLoader */
+ // $configLoader = $this->get(ConfigLoader::class);
+ // $configLoader->hydrate($result);
+ // }
+
+ return $result;
+ }
+
+ /**
+ * @template T
+ * @param class-string $id Service identifier
+ * @param null|class-string|array|\Closure(mixed ...): T $binding
+ */
+ public function bind(string $id, \Closure|string|array|null $binding = null): void
+ {
+ if (\is_string($binding)) {
+ \class_exists($binding) or throw new \InvalidArgumentException(
+ "Class `$binding` does not exist.",
+ );
+
+ /** @var class-string $binding */
+ $binding = \is_a($binding, Factoriable::class, true)
+ ? fn(): object => $this->injector->invoke([$binding, 'create'])
+ : fn(): object => $this->injector->make($binding);
+ }
+
+ if ($binding !== null) {
+ $this->factory[$id] = $binding;
+ return;
+ }
+
+ (\class_exists($id) && \is_a($id, Factoriable::class, true)) or throw new \InvalidArgumentException(
+ "Class `$id` must have a factory or be a factory itself and implement `Factoriable`.",
+ );
+
+ /** @var \Closure(mixed ...): T $object */
+ $object = $id::create(...);
+ $this->factory[$id] = $object;
+ }
+
+ public function destroy(): void
+ {
+ unset($this->cache, $this->factory, $this->injector);
+ }
+}
diff --git a/src/Module/Tokenizer/DefinitionLocator.php b/src/Module/Tokenizer/DefinitionLocator.php
new file mode 100644
index 0000000..239f74f
--- /dev/null
+++ b/src/Module/Tokenizer/DefinitionLocator.php
@@ -0,0 +1,128 @@
+
+ */
+ public static function getClasses(TokenizedFile $file): array
+ {
+ $classes = [];
+ foreach ($file->getClasses() as $class) {
+ try {
+ $classes[$class] = self::classReflection($class);
+ } catch (LocatorException $e) {
+ // if ($file->isDebug()) {
+ // throw $e;
+ // }
+
+ //Ignoring
+ continue;
+ }
+ }
+
+ return $classes;
+ }
+
+ /**
+ * Safely get class reflection, class loading errors will be blocked and reflection will be
+ * excluded from analysis.
+ *
+ * @template T
+ * @param class-string $class
+ * @return \ReflectionClass
+ *
+ * @throws LocatorException
+ */
+ private static function classReflection(string $class): \ReflectionClass
+ {
+ $loader = static function ($class): void {
+ if ($class === LocatorException::class) {
+ return;
+ }
+
+ throw new LocatorException(\sprintf("Class '%s' can not be loaded", $class));
+ };
+
+ //To suspend class dependency exception
+ \spl_autoload_register($loader);
+
+ try {
+ //In some cases reflection can thrown an exception if class invalid or can not be loaded,
+ //we are going to handle such exception and convert it soft exception
+ return new \ReflectionClass($class);
+ } catch (\Throwable $e) {
+ if ($e instanceof LocatorException && $e->getPrevious() != null) {
+ $e = $e->getPrevious();
+ }
+
+ // if ($this->debug) {
+ // $this->getLogger()->error(
+ // \sprintf('%s: %s in %s:%s', $class, $e->getMessage(), $e->getFile(), $e->getLine()),
+ // ['error' => $e],
+ // );
+ // }
+
+ throw new LocatorException($e->getMessage(), (int) $e->getCode(), $e);
+ } finally {
+ \spl_autoload_unregister($loader);
+ }
+ }
+
+ /**
+ * Safely get enum reflection, class loading errors will be blocked and reflection will be
+ * excluded from analysis.
+ *
+ * @param class-string $enum
+ *
+ * @throws LocatorException
+ */
+ private function enumReflection(string $enum): \ReflectionEnum
+ {
+ $loader = static function (string $enum): void {
+ if ($enum === LocatorException::class) {
+ return;
+ }
+
+ throw new LocatorException(\sprintf("Enum '%s' can not be loaded", $enum));
+ };
+
+ //To suspend class dependency exception
+ \spl_autoload_register($loader);
+
+ try {
+ //In some enum reflection can thrown an exception if enum invalid or can not be loaded,
+ //we are going to handle such exception and convert it soft exception
+ return new \ReflectionEnum($enum);
+ } catch (\Throwable $e) {
+ if ($e instanceof LocatorException && $e->getPrevious() != null) {
+ $e = $e->getPrevious();
+ }
+
+ // if ($this->debug) {
+ // $this->getLogger()->error(
+ // \sprintf('%s: %s in %s:%s', $enum, $e->getMessage(), $e->getFile(), $e->getLine()),
+ // ['error' => $e],
+ // );
+ // }
+
+ throw new LocatorException($e->getMessage(), (int) $e->getCode(), $e);
+ } finally {
+ \spl_autoload_unregister($loader);
+ }
+ }
+}
diff --git a/src/Module/Tokenizer/Exception/LocatorException.php b/src/Module/Tokenizer/Exception/LocatorException.php
new file mode 100644
index 0000000..4759466
--- /dev/null
+++ b/src/Module/Tokenizer/Exception/LocatorException.php
@@ -0,0 +1,10 @@
+
+ */
+final class FileLocator implements \IteratorAggregate
+{
+ protected readonly Finder $finder;
+
+ public function __construct(
+ Finder $finder,
+ protected readonly bool $debug = false,
+ ) {
+ $this->finder = $finder->files();
+ }
+
+ /**
+ * Available file reflections. Generator.
+ *
+ * @return \Generator
+ * @throws \Exception
+ */
+ public function getIterator(): \Generator
+ {
+ foreach ($this->finder->getIterator() as $file) {
+ yield new TokenizedFile($file, (string) $file);
+ }
+ }
+}
diff --git a/src/Module/Tokenizer/Reflection.php b/src/Module/Tokenizer/Reflection.php
new file mode 100644
index 0000000..06d94be
--- /dev/null
+++ b/src/Module/Tokenizer/Reflection.php
@@ -0,0 +1,116 @@
+getAttributes($attributeClass, $flags));
+
+ if ($includeParents && $function instanceof \ReflectionMethod) {
+ $parentClass = $function->getDeclaringClass()->getParentClass();
+ if ($parentClass !== false && $parentClass->hasMethod($function->getName())) {
+ $function = $parentClass->getMethod($function->getName());
+ continue;
+ }
+ }
+
+ break;
+ } while (true);
+
+ return $attributes;
+ }
+
+ /**
+ * Fetch all attributes for a given class.
+ *
+ * @param class-string $class
+ * @param bool $includeParents Whether to include attributes from parent classes.
+ * @param bool $includeTraits Whether to include attributes from traits.
+ * @param class-string|null $attributeClass If provided, only attributes of this class will be returned.
+ * @param int $flags Flags to pass to {@see ReflectionClass::getAttributes()}.
+ *
+ * @return \ReflectionAttribute[]
+ */
+ public static function fetchClassAttributes(
+ \ReflectionClass|string $class,
+ bool $includeParents = true,
+ bool $includeTraits = true,
+ ?string $attributeClass = null,
+ int $flags = 0,
+ ): array {
+ $attributes = [];
+
+ do {
+ \is_string($class) and $reflection = new \ReflectionClass($class);
+ $attributes = \array_merge(
+ $attributes,
+ $reflection->getAttributes($attributeClass, $flags),
+ );
+
+ if ($includeTraits) {
+ foreach (self::fetchTraits($class->getName(), includeParents: false) as $trait) {
+ $traitReflection = new \ReflectionClass($trait);
+ $attributes = \array_merge(
+ $attributes,
+ $traitReflection->getAttributes($attributeClass, $flags),
+ );
+ }
+ }
+
+ $class = $includeParents ? $reflection->getParentClass() : null;
+ } while ($class !== null);
+
+ return $attributes;
+ }
+
+ /**
+ * Get every class trait (including traits used in parents).
+ *
+ * @param class-string $class
+ * @param bool $includeParents Whether to include traits from parent classes.
+ *
+ * @return non-empty-string[]
+ */
+ public static function fetchTraits(
+ string $class,
+ bool $includeParents = true,
+ ): array {
+ $traits = [];
+
+ do {
+ $traits = \array_merge(\class_uses($class), $traits);
+ $class = \get_parent_class($class);
+ } while ($includeParents && $class !== false);
+
+ //Traits from traits
+ foreach (\array_flip($traits) as $trait) {
+ $traits = \array_merge(\class_uses($trait), $traits);
+ }
+
+ return \array_unique($traits);
+ }
+}
diff --git a/src/Module/Tokenizer/Reflection/FileDefinitions.php b/src/Module/Tokenizer/Reflection/FileDefinitions.php
new file mode 100644
index 0000000..be7fa5c
--- /dev/null
+++ b/src/Module/Tokenizer/Reflection/FileDefinitions.php
@@ -0,0 +1,56 @@
+
+ */
+ public readonly array $classes;
+
+ /**
+ * Interface reflections found in the file.
+ * @var array
+ */
+ public readonly array $interfaces;
+
+ /**
+ * Enum reflections found in the file.
+ * @var array
+ */
+ public readonly array $enums;
+
+ /**
+ * Function reflections found in the file.
+ * @var array
+ */
+ public readonly array $functions;
+
+ /**
+ * Trait reflections found in the file.
+ * @var array
+ */
+ public readonly array $traits;
+
+ public function __construct(
+ public readonly TokenizedFile $tokenizedFile,
+ public readonly CaseDefinitions $cases = new CaseDefinitions(),
+ ) {
+ $this->classes = DefinitionLocator::getClasses($tokenizedFile);
+ $this->enums = [];
+ // $this->enums = DefinitionLocator::getEnums($tokenizedFile);
+ $this->functions = [];
+ // $this->functions = DefinitionLocator::getFunctions($tokenizedFile);
+ $this->interfaces = [];
+ // $this->interfaces = DefinitionLocator::getInterfaces($tokenizedFile);
+ $this->traits = [];
+ // $this->traits = DefinitionLocator::getTraits($tokenizedFile);
+ }
+}
diff --git a/src/Module/Tokenizer/Reflection/TokenizedArgument.php b/src/Module/Tokenizer/Reflection/TokenizedArgument.php
new file mode 100644
index 0000000..1e5cfe3
--- /dev/null
+++ b/src/Module/Tokenizer/Reflection/TokenizedArgument.php
@@ -0,0 +1,140 @@
+ self::EXPRESSION, 'value' => '', 'tokens' => []];
+ }
+
+ if ($token[TokenizedFile::TOKEN_TYPE] === '(' || $token[TokenizedFile::TOKEN_TYPE] === '[') {
+ ++$level;
+ $definition['value'] .= $token[TokenizedFile::TOKEN_CODE];
+ continue;
+ }
+
+ if ($token[TokenizedFile::TOKEN_TYPE] === ')' || $token[TokenizedFile::TOKEN_TYPE] === ']') {
+ --$level;
+ $definition['value'] .= $token[TokenizedFile::TOKEN_CODE];
+ continue;
+ }
+
+ if ($level) {
+ $definition['value'] .= $token[TokenizedFile::TOKEN_CODE];
+ continue;
+ }
+
+ if ($token[TokenizedFile::TOKEN_TYPE] === ',') {
+ $result[] = self::createArgument($definition);
+ $definition = null;
+ continue;
+ }
+
+ $definition['tokens'][] = $token;
+ $definition['value'] .= $token[TokenizedFile::TOKEN_CODE];
+ }
+
+ //Last argument
+ if (\is_array($definition)) {
+ $definition = self::createArgument($definition);
+ if (!empty($definition->getType())) {
+ $result[] = $definition;
+ }
+ }
+
+ return $result;
+ }
+
+ public function getType(): string
+ {
+ return $this->type;
+ }
+
+ public function getValue(): string
+ {
+ return $this->value;
+ }
+
+ /**
+ * Convert argument value into valid string. Can be applied only for STRING type arguments.
+ *
+ * @throws ReflectionException When value can not be converted into string.
+ */
+ public function stringValue(): string
+ {
+ if ($this->type !== self::STRING) {
+ throw new ReflectionException(
+ \sprintf("Unable to represent value as string, value type is '%s'", $this->type),
+ );
+ }
+
+ //The most reliable way
+ return eval("return {$this->value};");
+ }
+
+ /**
+ * Create Argument reflection using token definition. Internal method.
+ *
+ * @param array{value: string, tokens: array, type: string} $definition
+ * @see locateArguments
+ */
+ private static function createArgument(array $definition): TokenizedArgument
+ {
+ $result = new self(self::EXPRESSION, $definition['value']);
+
+ if (\count($definition['tokens']) == 1) {
+ $result->type = match ($definition['tokens'][0][0]) {
+ T_VARIABLE => self::VARIABLE,
+ T_LNUMBER, T_DNUMBER => self::CONSTANT,
+ T_CONSTANT_ENCAPSED_STRING => self::STRING,
+ default => $result->type,
+ };
+ }
+
+ return $result;
+ }
+}
diff --git a/src/Module/Tokenizer/Reflection/TokenizedFile.php b/src/Module/Tokenizer/Reflection/TokenizedFile.php
new file mode 100644
index 0000000..9e79cd4
--- /dev/null
+++ b/src/Module/Tokenizer/Reflection/TokenizedFile.php
@@ -0,0 +1,720 @@
+path = Path::create($path);
+ $this->tokens = self::fetchTokens($this->path);
+ $this->countTokens = \count($this->tokens);
+
+ //Looking for declarations
+ $this->locateDeclarations();
+ }
+
+ /**
+ * List of declared function names
+ */
+ public function getFunctions(): array
+ {
+ return \array_keys($this->functions);
+ }
+
+ /**
+ * List of declared class names
+ */
+ public function getClasses(): array
+ {
+ if (!isset($this->declarations['T_CLASS'])) {
+ return [];
+ }
+
+ return \array_keys($this->declarations['T_CLASS']);
+ }
+
+ /**
+ * List of declared enums names
+ */
+ public function getEnums(): array
+ {
+ if (!isset($this->declarations['T_ENUM'])) {
+ return [];
+ }
+
+ return \array_keys($this->declarations['T_ENUM']);
+ }
+
+ /**
+ * List of declared trait names
+ */
+ public function getTraits(): array
+ {
+ if (!isset($this->declarations['T_TRAIT'])) {
+ return [];
+ }
+
+ return \array_keys($this->declarations['T_TRAIT']);
+ }
+
+ /**
+ * List of declared interface names
+ */
+ public function getInterfaces(): array
+ {
+ if (!isset($this->declarations['T_INTERFACE'])) {
+ return [];
+ }
+
+ return \array_keys($this->declarations['T_INTERFACE']);
+ }
+
+ /**
+ * Locate and return list of every method or function call in specified file. Only static and
+ * $this calls will be indexed
+ *
+ * @return TokenizedInvocation[]
+ */
+ public function getInvocations(): array
+ {
+ if (empty($this->invocations)) {
+ $this->locateInvocations($this->tokens);
+ }
+
+ return $this->invocations;
+ }
+
+ /**
+ * Export found declaration as array for caching purposes.
+ */
+ public function exportSchema(): array
+ {
+ return [$this->hasIncludes, $this->declarations, $this->functions, $this->namespaces];
+ }
+
+ /**
+ * Import cached reflection schema.
+ */
+ protected function importSchema(array $cache): void
+ {
+ [$this->hasIncludes, $this->declarations, $this->functions, $this->namespaces] = $cache;
+ }
+
+ /**
+ * Locate every class, interface, trait or function definition.
+ */
+ protected function locateDeclarations(): void
+ {
+ $hasIncludes = false;
+ foreach ($this->tokens as $tokenID => $token) {
+ if (!\in_array($token[self::TOKEN_TYPE], self::$processTokens)) {
+ continue;
+ }
+
+ switch ($token[self::TOKEN_TYPE]) {
+ case T_NAMESPACE:
+ $this->registerNamespace($tokenID);
+ break;
+
+ case T_USE:
+ $this->registerUse($tokenID);
+ break;
+
+ case T_FUNCTION:
+ $this->registerFunction($tokenID);
+ break;
+
+ case T_CLASS:
+ case T_TRAIT:
+ case T_INTERFACE:
+ case T_ENUM:
+ if ($this->isClassNameConst($tokenID)) {
+ // PHP5.5 ClassName::class constant
+ continue 2;
+ }
+
+ if ($this->isAnonymousClass($tokenID)) {
+ // PHP7.0 Anonymous classes new class ('foo', 'bar')
+ continue 2;
+ }
+
+ if (!$this->isCorrectDeclaration($tokenID)) {
+ // PHP8.0 Named parameters ->foo(class: 'bar')
+ continue 2;
+ }
+
+ $this->registerDeclaration($tokenID, $token[self::TOKEN_TYPE]);
+ break;
+
+ case T_INCLUDE:
+ case T_INCLUDE_ONCE:
+ case T_REQUIRE:
+ case T_REQUIRE_ONCE:
+ $hasIncludes = true;
+ }
+ }
+
+ $this->hasIncludes = $hasIncludes;
+
+ //Dropping empty namespace
+ if (isset($this->namespaces[''])) {
+ $this->namespaces['\\'] = $this->namespaces[''];
+ unset($this->namespaces['']);
+ }
+ }
+
+ /**
+ * Get all tokes for specific file.
+ */
+ private static function fetchTokens(Path $filename): array
+ {
+ $tokens = \token_get_all(\file_get_contents((string) $filename));
+
+ $line = 0;
+ foreach ($tokens as &$token) {
+ isset($token[self::TOKEN_LINE]) and $line = $token[self::TOKEN_LINE];
+ \is_array($token) or $token = [$token, $token, $line];
+ unset($token);
+ }
+
+ return $tokens;
+ }
+
+ /**
+ * Handle namespace declaration.
+ */
+ private function registerNamespace(int $tokenID): void
+ {
+ $namespace = '';
+ $localID = $tokenID + 1;
+
+ do {
+ $token = $this->tokens[$localID++];
+ if ($token[self::TOKEN_CODE] === '{') {
+ break;
+ }
+
+ $namespace .= $token[self::TOKEN_CODE];
+ } while (
+ isset($this->tokens[$localID])
+ && $this->tokens[$localID][self::TOKEN_CODE] !== '{'
+ && $this->tokens[$localID][self::TOKEN_CODE] !== ';'
+ );
+
+ //Whitespaces
+ $namespace = \trim($namespace);
+
+ $uses = [];
+ if (isset($this->namespaces[$namespace])) {
+ $uses = $this->namespaces[$namespace];
+ }
+
+ if ($this->tokens[$localID][self::TOKEN_CODE] === ';') {
+ $endingID = \count($this->tokens) - 1;
+ } else {
+ $endingID = $this->endingToken($tokenID);
+ }
+
+ $this->namespaces[$namespace] = [
+ self::O_TOKEN => $tokenID,
+ self::C_TOKEN => $endingID,
+ self::N_USES => $uses,
+ ];
+ }
+
+ /**
+ * Handle use (import class from another namespace).
+ */
+ private function registerUse(int $tokenID): void
+ {
+ $namespace = \rtrim($this->activeNamespace($tokenID), '\\');
+
+ $class = '';
+ $localAlias = null;
+ for ($localID = $tokenID + 1; $this->tokens[$localID][self::TOKEN_CODE] !== ';'; ++$localID) {
+ if ($this->tokens[$localID][self::TOKEN_TYPE] == T_AS) {
+ $localAlias = '';
+ continue;
+ }
+
+ if ($localAlias === null) {
+ $class .= $this->tokens[$localID][self::TOKEN_CODE];
+ } else {
+ $localAlias .= $this->tokens[$localID][self::TOKEN_CODE];
+ }
+ }
+
+ if (empty($localAlias)) {
+ $names = \explode('\\', $class);
+ $localAlias = \end($names);
+ }
+
+ $this->namespaces[$namespace][self::N_USES][\trim($localAlias)] = \trim($class);
+ }
+
+ /**
+ * Handle function declaration (function creation).
+ */
+ private function registerFunction(int $tokenID): void
+ {
+ foreach ($this->declarations as $declarations) {
+ foreach ($declarations as $location) {
+ if ($tokenID >= $location[self::O_TOKEN] && $tokenID <= $location[self::C_TOKEN]) {
+ //We are inside class, function is method
+ return;
+ }
+ }
+ }
+
+ $localID = $tokenID + 1;
+ while ($this->tokens[$localID][self::TOKEN_TYPE] !== T_STRING) {
+ //Fetching function name
+ ++$localID;
+ }
+
+ $name = $this->tokens[$localID][self::TOKEN_CODE];
+ if (!empty($namespace = $this->activeNamespace($tokenID))) {
+ $name = $namespace . self::NS_SEPARATOR . $name;
+ }
+
+ $this->functions[$name] = [
+ self::O_TOKEN => $tokenID,
+ self::C_TOKEN => $this->endingToken($tokenID),
+ ];
+ }
+
+ /**
+ * Handle declaration of class, trait of interface. Declaration will be stored under it's token
+ * type in declarations array.
+ */
+ private function registerDeclaration(int $tokenID, int $tokenType): void
+ {
+ $localID = $tokenID + 1;
+ while ($this->tokens[$localID][self::TOKEN_TYPE] !== T_STRING) {
+ ++$localID;
+ }
+
+ $name = $this->tokens[$localID][self::TOKEN_CODE];
+ if (!empty($namespace = $this->activeNamespace($tokenID))) {
+ $name = $namespace . self::NS_SEPARATOR . $name;
+ }
+
+ $this->declarations[\token_name($tokenType)][$name] = [
+ self::O_TOKEN => $tokenID,
+ self::C_TOKEN => $this->endingToken($tokenID),
+ ];
+ }
+
+ /**
+ * Check if token ID represents `ClassName::class` constant statement.
+ */
+ private function isClassNameConst(int $tokenID): bool
+ {
+ return $this->tokens[$tokenID][self::TOKEN_TYPE] === T_CLASS
+ && isset($this->tokens[$tokenID - 1])
+ && $this->tokens[$tokenID - 1][self::TOKEN_TYPE] === T_PAAMAYIM_NEKUDOTAYIM;
+ }
+
+ /**
+ * Check if token ID represents anonymous class creation, e.g. `new class ('foo', 'bar')`.
+ */
+ private function isAnonymousClass(int|string $tokenID): bool
+ {
+ return $this->tokens[$tokenID][self::TOKEN_TYPE] === T_CLASS
+ && isset($this->tokens[$tokenID - 2])
+ && $this->tokens[$tokenID - 2][self::TOKEN_TYPE] === T_NEW;
+ }
+
+ /**
+ * Check if token ID represents named parameter with name `class`, e.g. `foo(class: SomeClass::name)`.
+ */
+ private function isCorrectDeclaration(int|string $tokenID): bool
+ {
+ return \in_array($this->tokens[$tokenID][self::TOKEN_TYPE], [T_CLASS, T_TRAIT, T_INTERFACE, T_ENUM], true)
+ && isset($this->tokens[$tokenID + 2])
+ && $this->tokens[$tokenID + 1][self::TOKEN_TYPE] === T_WHITESPACE
+ && $this->tokens[$tokenID + 2][self::TOKEN_TYPE] === T_STRING;
+ }
+
+ /**
+ * Locate every function or static method call (including $this calls).
+ *
+ * This is pretty old code, potentially to be improved using AST.
+ *
+ * @param array $tokens
+ */
+ private function locateInvocations(array $tokens, int $invocationLevel = 0): void
+ {
+ //Multiple "(" and ")" statements nested.
+ $level = 0;
+
+ //Skip all tokens until next function
+ $ignore = false;
+
+ //Were function was found
+ $invocationTID = 0;
+
+ //Parsed arguments and their first token id
+ $arguments = [];
+ $argumentsTID = false;
+
+ //Tokens used to re-enable token detection
+ $stopTokens = [T_STRING, T_WHITESPACE, T_DOUBLE_COLON, T_OBJECT_OPERATOR, T_NS_SEPARATOR];
+ foreach ($tokens as $tokenID => $token) {
+ $tokenType = $token[self::TOKEN_TYPE];
+
+ //We are not indexing function declarations or functions called from $objects.
+ if (\in_array($tokenType, [T_FUNCTION, T_OBJECT_OPERATOR, T_NEW])) {
+ if (
+ empty($argumentsTID)
+ && (
+ empty($invocationTID)
+ || $this->getSource($invocationTID, $tokenID - 1) !== '$this'
+ )
+ ) {
+ //Not a call, function declaration, or object method
+ $ignore = true;
+ continue;
+ }
+ } elseif ($ignore) {
+ if (!\in_array($tokenType, $stopTokens)) {
+ //Returning to search
+ $ignore = false;
+ }
+ continue;
+ }
+
+ //We are inside function, and there is "(", indexing arguments.
+ if (!empty($invocationTID) && ($tokenType === '(' || $tokenType === '[')) {
+ if (empty($argumentsTID)) {
+ $argumentsTID = $tokenID;
+ }
+
+ ++$level;
+ if ($level != 1) {
+ //Not arguments beginning, but arguments part
+ $arguments[$tokenID] = $token;
+ }
+
+ continue;
+ }
+
+ //We are inside function arguments and ")" met.
+ if (!empty($invocationTID) && ($tokenType === ')' || $tokenType === ']')) {
+ --$level;
+ if ($level == -1) {
+ $invocationTID = false;
+ $level = 0;
+ continue;
+ }
+
+ //Function fully indexed, we can process it now.
+ if ($level == 0) {
+ $this->registerInvocation(
+ $invocationTID,
+ $argumentsTID,
+ $tokenID,
+ $arguments,
+ $invocationLevel,
+ );
+
+ //Closing search
+ $arguments = [];
+ $argumentsTID = $invocationTID = false;
+ } else {
+ //Not arguments beginning, but arguments part
+ $arguments[$tokenID] = $token;
+ }
+
+ continue;
+ }
+
+ //Still inside arguments.
+ if (!empty($invocationTID) && !empty($level)) {
+ $arguments[$tokenID] = $token;
+ continue;
+ }
+
+ //Nothing valuable to remember, will be parsed later.
+ if (!empty($invocationTID) && \in_array($tokenType, $stopTokens)) {
+ continue;
+ }
+
+ //Seems like we found function/method call
+ if (
+ $tokenType == T_STRING
+ || $tokenType == T_STATIC
+ || $tokenType == T_NS_SEPARATOR
+ || ($tokenType == T_VARIABLE && $token[self::TOKEN_CODE] === '$this')
+ ) {
+ $invocationTID = $tokenID;
+ $level = 0;
+
+ $argumentsTID = false;
+ continue;
+ }
+
+ //Returning to search
+ $invocationTID = false;
+ $arguments = [];
+ }
+ }
+
+ /**
+ * Registering invocation.
+ */
+ private function registerInvocation(
+ int $invocationID,
+ int $argumentsID,
+ int $endID,
+ array $arguments,
+ int $invocationLevel,
+ ): void {
+ //Nested invocations
+ $this->locateInvocations($arguments, $invocationLevel + 1);
+
+ [$class, $operator, $name] = $this->fetchContext($invocationID, $argumentsID);
+
+ if (!empty($operator) && empty($class)) {
+ //Non detectable
+ return;
+ }
+
+ $this->invocations[] = new TokenizedInvocation(
+ $this->path,
+ $this->lineNumber($invocationID),
+ $class,
+ $operator,
+ $name,
+ TokenizedArgument::locateArguments($arguments),
+ $this->getSource($invocationID, $endID),
+ $invocationLevel,
+ );
+ }
+
+ /**
+ * Fetching invocation context.
+ * @return array{class-string|"", "::"|"->"|"", non-empty-string} [class, operator, name]
+ */
+ private function fetchContext(int $invocationTID, int $argumentsTID): array
+ {
+ $class = $operator = '';
+ $name = \trim($this->getSource($invocationTID, $argumentsTID), '( ');
+
+ //Let's try to fetch all information we need
+ if (\str_contains($name, '->')) {
+ $operator = '->';
+ } elseif (\str_contains($name, '::')) {
+ $operator = '::';
+ }
+
+ if (!empty($operator)) {
+ [$class, $name] = \explode($operator, $name);
+
+ //We now have to clarify class name
+ if (\in_array($class, ['self', 'static', '$this'])) {
+ $class = $this->activeDeclaration($invocationTID);
+ }
+ }
+
+ return [$class, $operator, $name];
+ }
+
+ /**
+ * Get declaration which is active in given token position.
+ */
+ private function activeDeclaration(int $tokenID): string
+ {
+ foreach ($this->declarations as $declarations) {
+ foreach ($declarations as $name => $position) {
+ if ($tokenID >= $position[self::O_TOKEN] && $tokenID <= $position[self::C_TOKEN]) {
+ return $name;
+ }
+ }
+ }
+
+ //Can not be detected
+ return '';
+ }
+
+ /**
+ * Get namespace name active at specified token position.
+ */
+ private function activeNamespace(int $tokenID): string
+ {
+ foreach ($this->namespaces as $namespace => $position) {
+ if ($tokenID >= $position[self::O_TOKEN] && $tokenID <= $position[self::C_TOKEN]) {
+ return $namespace;
+ }
+ }
+
+ //Seems like no namespace declaration
+ $this->namespaces[''] = [
+ self::O_TOKEN => 0,
+ self::C_TOKEN => \count($this->tokens),
+ self::N_USES => [],
+ ];
+
+ return '';
+ }
+
+ /**
+ * Find token ID of ending brace.
+ */
+ private function endingToken(int $tokenID): int
+ {
+ $level = null;
+ for ($localID = $tokenID; $localID < $this->countTokens; ++$localID) {
+ $token = $this->tokens[$localID];
+ if ($token[self::TOKEN_CODE] === '{') {
+ ++$level;
+ continue;
+ }
+
+ if ($token[self::TOKEN_CODE] === '}') {
+ --$level;
+ }
+
+ if ($level === 0) {
+ break;
+ }
+ }
+
+ return $localID;
+ }
+
+ /**
+ * Get line number associated with token.
+ */
+ private function lineNumber(int $tokenID): int
+ {
+ while (empty($this->tokens[$tokenID][self::TOKEN_LINE])) {
+ --$tokenID;
+ }
+
+ return $this->tokens[$tokenID][self::TOKEN_LINE];
+ }
+
+ /**
+ * Get src located between two tokens.
+ */
+ private function getSource(int $startID, int $endID): string
+ {
+ $result = '';
+ for ($tokenID = $startID; $tokenID <= $endID; ++$tokenID) {
+ //Collecting function usage src
+ $result .= $this->tokens[$tokenID][self::TOKEN_CODE];
+ }
+
+ return $result;
+ }
+}
diff --git a/src/Module/Tokenizer/Reflection/TokenizedInvocation.php b/src/Module/Tokenizer/Reflection/TokenizedInvocation.php
new file mode 100644
index 0000000..d6b7c91
--- /dev/null
+++ b/src/Module/Tokenizer/Reflection/TokenizedInvocation.php
@@ -0,0 +1,83 @@
+).
+ * @var '::'|'->'|''
+ */
+ public readonly string $operator,
+ /**
+ * Function or method name.
+ * @var non-empty-string
+ */
+ public readonly string $name,
+ /**
+ * All parsed function arguments.
+ *
+ * @var TokenizedArgument[]
+ */
+ public readonly array $arguments,
+ /**
+ * Function usage src.
+ */
+ public readonly string $source,
+ /**
+ * Invoking level.
+ */
+ public readonly int $level,
+ ) {}
+
+ /**
+ * Call made by class method.
+ */
+ public function isMethod(): bool
+ {
+ return !empty($this->class);
+ }
+
+ /**
+ * Get call argument by it position.
+ */
+ public function getArgument(int $index): TokenizedArgument
+ {
+ if (!isset($this->arguments[$index])) {
+ throw new ReflectionException(\sprintf("No such argument with index '%d'", $index));
+ }
+
+ return $this->arguments[$index];
+ }
+}
diff --git a/src/Suite/Dto/CaseDefinitions.php b/src/Suite/Dto/CaseDefinitions.php
new file mode 100644
index 0000000..06b23b3
--- /dev/null
+++ b/src/Suite/Dto/CaseDefinitions.php
@@ -0,0 +1,47 @@
+
+ */
+ private array $cases = [];
+
+ public static function fromArray(CaseDefinition ...$values): self
+ {
+ $self = new self();
+ $self->cases = \array_values($values);
+ return $self;
+ }
+
+ public function define(?\ReflectionClass $reflection): CaseDefinition
+ {
+ foreach ($this->cases as $case) {
+ if ($case->reflection === $reflection) {
+ return $case;
+ }
+ }
+
+ return $this->cases[] = new CaseDefinition($reflection);
+ }
+
+ /**
+ * Get all located test cases.
+ *
+ * @return list
+ */
+ public function getCases(): array
+ {
+ return $this->cases;
+ }
+}
diff --git a/src/Suite/Dto/SuiteInfo.php b/src/Suite/Dto/SuiteInfo.php
new file mode 100644
index 0000000..f5b9da6
--- /dev/null
+++ b/src/Suite/Dto/SuiteInfo.php
@@ -0,0 +1,14 @@
+
+ */
+final class SuiteResult implements \IteratorAggregate
+{
+ public function __construct(
+ /**
+ * Test result collection.
+ *
+ * @var iterable
+ */
+ public readonly iterable $results,
+ ) {}
+
+ /**
+ * @return \Traversable
+ */
+ public function getIterator(): \Traversable
+ {
+ yield from $this->results;
+ }
+}
diff --git a/src/Suite/SuiteCollector.php b/src/Suite/SuiteCollector.php
new file mode 100644
index 0000000..1a0a4bf
--- /dev/null
+++ b/src/Suite/SuiteCollector.php
@@ -0,0 +1,133 @@
+ */
+ private array $suites = [];
+
+ public function __construct(
+ // private readonly ClassLoader $classLoader,
+ private readonly InterceptorProvider $interceptorProvider,
+ ) {}
+
+ public function get(string $name): ?SuiteInfo
+ {
+ return $this->suites[$name] ?? null;
+ }
+
+ public function getOrCreate(SuiteConfig $config): SuiteInfo
+ {
+ return $this->suites[$config->name] ??= $this->createInfo($config);
+ }
+
+ private function createInfo(SuiteConfig $config): SuiteInfo
+ {
+ $files = $this->getFilesIterator($config);
+ $definitions = $this->getCaseDefinitions($config, $files);
+
+ $cases = [];
+ foreach ($definitions as $definition) {
+ # Skip empty test cases
+ if ($definition->tests === []) {
+ continue;
+ }
+
+ $cases[] = $definition;
+ }
+
+ return new SuiteInfo(
+ name: $config->name,
+ testCases: CaseDefinitions::fromArray(...$cases),
+ );
+ }
+
+ /**
+ * Locate test files based on the suite configuration and {@see FileLocatorInterceptor} interceptors.
+ *
+ * @return iterable
+ */
+ private function getFilesIterator(SuiteConfig $config): iterable
+ {
+ $locator = new FileLocator(new Finder($config->location));
+
+ # Prepare interceptors pipeline
+ $interceptors = $this->interceptorProvider->fromClasses(FileLocatorInterceptor::class);
+
+ # todo remove:
+ // $interceptors[] = new FilePostfixTestLocatorInterceptor();
+ $interceptors[] = new TestoAttributesLocatorInterceptor();
+
+ /**
+ * @see FileLocatorInterceptor::locateFile()
+ * @var callable(TokenizedFile): (null|bool) $pipeline
+ */
+ $pipeline = Pipeline::prepare(...$interceptors)
+ ->with(static fn(TokenizedFile $file): ?bool => null, 'locateFile');
+
+ foreach ($locator->getIterator() as $fileReflection) {
+ $match = $pipeline($fileReflection);
+
+ if ($match === true) {
+ yield $fileReflection;
+ }
+ }
+ }
+
+ /**
+ * Fetch test case definitions from the given files using {@see CaseLocatorInterceptor} interceptors.
+ *
+ * @param iterable $files
+ * @return list
+ */
+ private function getCaseDefinitions(SuiteConfig $config, iterable $files): array
+ {
+ $cases = [];
+ # Prepare interceptors pipeline
+ $interceptors = $this->interceptorProvider->fromClasses(CaseLocatorInterceptor::class);
+
+ // todo remove:
+ $interceptors[] = new FilePostfixTestLocatorInterceptor();
+
+ /**
+ * @see CaseLocatorInterceptor::locateTestCases()
+ * @var callable(FileDefinitions): CaseDefinitions $pipeline
+ */
+ $pipeline = Pipeline::prepare(...$interceptors)
+ ->with(
+ static fn(FileDefinitions $definitions): CaseDefinitions => $definitions->cases,
+ 'locateTestCases',
+ );
+
+ foreach ($files as $file) {
+ $fileDef = new FileDefinitions($file);
+ $result = $pipeline($fileDef);
+
+ $cases = \array_merge($cases, $result->getCases());
+ }
+
+ return $cases;
+ }
+}
diff --git a/src/Suite/SuiteProvider.php b/src/Suite/SuiteProvider.php
new file mode 100644
index 0000000..03450dd
--- /dev/null
+++ b/src/Suite/SuiteProvider.php
@@ -0,0 +1,63 @@
+ */
+ private readonly array $configs;
+
+ public function __construct(
+ ApplicationConfig $applicationConfig,
+ private readonly SuiteCollector $collector,
+ ) {
+ $this->configs = $applicationConfig->suites;
+ }
+
+ /**
+ * @psalm-immutable
+ */
+ public function withFilter(Filter $filter): self
+ {
+ # Apply suite name filter if exists
+ if ($filter->testSuites === []) {
+ return $this;
+ }
+
+ $suites = \array_filter(
+ $this->configs,
+ static fn(SuiteConfig $suite) => \in_array($suite->name, $filter->testSuites, true),
+ );
+
+ /** @see self::$suites */
+ return $this->with('suites', $suites);
+ }
+
+ /**
+ * Gets test suite definitions with applied filter.
+ *
+ * @return array
+ */
+ public function getSuites(): array
+ {
+ $result = [];
+ foreach ($this->configs as $config) {
+ $result[] = $this->collector->getOrCreate($config);
+ }
+
+ return $result;
+ }
+}
diff --git a/src/Suite/SuiteRunner.php b/src/Suite/SuiteRunner.php
new file mode 100644
index 0000000..b3e4c36
--- /dev/null
+++ b/src/Suite/SuiteRunner.php
@@ -0,0 +1,45 @@
+name === null or $filter = $filter->withTestSuites($suite->name);
+
+ // todo if random, run in random order?
+
+ $runner = $this->caseRunner;
+ $results = [];
+ # Run tests for each case
+ foreach ($suite->testCases->getCases() as $caseDefinition) {
+ try {
+ $caseInfo = new CaseInfo(
+ definition: $caseDefinition,
+ );
+ $results[] = $runner->runCase($caseInfo, $filter);
+ } catch (\Throwable) {
+ // Skip for now
+ }
+ }
+
+ return new SuiteResult($results);
+ }
+}
diff --git a/src/Test/CaseRunner.php b/src/Test/CaseRunner.php
new file mode 100644
index 0000000..b430016
--- /dev/null
+++ b/src/Test/CaseRunner.php
@@ -0,0 +1,63 @@
+ $interceptors
+ * @var callable(CaseInfo): CaseResult $pipeline
+ */
+ $interceptors = $this->interceptorProvider->fromClasses(TestCaseCallInterceptor::class);
+
+ // todo remove
+ $interceptors[] = new TestCaseCallInterceptor\InstantiateTestCase();
+
+ $pipeline = Pipeline::prepare(...$interceptors)
+ ->with(
+ fn(CaseInfo $info): CaseResult => $this->run($info),
+ 'runTestCase',
+ );
+
+ return $pipeline($info);
+ }
+
+ public function run(CaseInfo $info): CaseResult
+ {
+ $results = [];
+ foreach ($info->definition->tests->getTests() as $testDefinition) {
+ $testInfo = new TestInfo(
+ caseInfo: $info,
+ testDefinition: $testDefinition,
+ );
+
+ $results[] = $this->testRunner->runTest($testInfo);
+ }
+
+ return new CaseResult($results);
+ }
+}
diff --git a/src/Test/Dto/CaseDefinition.php b/src/Test/Dto/CaseDefinition.php
new file mode 100644
index 0000000..b78b03a
--- /dev/null
+++ b/src/Test/Dto/CaseDefinition.php
@@ -0,0 +1,14 @@
+with('instance', $instance);
+ }
+}
diff --git a/src/Test/Dto/CaseResult.php b/src/Test/Dto/CaseResult.php
new file mode 100644
index 0000000..4da38f6
--- /dev/null
+++ b/src/Test/Dto/CaseResult.php
@@ -0,0 +1,27 @@
+
+ */
+final class CaseResult implements \IteratorAggregate
+{
+ public function __construct(
+ /**
+ * Test result collection.
+ *
+ * @var iterable
+ */
+ public readonly iterable $results,
+ ) {}
+
+ public function getIterator(): \Traversable
+ {
+ yield from $this->results;
+ }
+}
diff --git a/src/Test/Dto/Status.php b/src/Test/Dto/Status.php
new file mode 100644
index 0000000..635863e
--- /dev/null
+++ b/src/Test/Dto/Status.php
@@ -0,0 +1,43 @@
+
+ */
+ private array $tests = [];
+
+ public static function fromArray(TestDefinition ...$values): self
+ {
+ $self = new self();
+ $self->tests = \array_values($values);
+ return $self;
+ }
+
+ public function define(\ReflectionFunctionAbstract $reflection): TestDefinition
+ {
+ return $this->tests[$reflection->getShortName()] = new TestDefinition($reflection);
+ }
+
+ /**
+ * Get all located test cases.
+ *
+ * @return array
+ */
+ public function getTests(): array
+ {
+ return $this->tests;
+ }
+}
diff --git a/src/Test/Dto/TestInfo.php b/src/Test/Dto/TestInfo.php
new file mode 100644
index 0000000..dd172aa
--- /dev/null
+++ b/src/Test/Dto/TestInfo.php
@@ -0,0 +1,16 @@
+info,
+ result: $this->result,
+ status: $status ?? $this->status,
+ );
+ }
+
+ public function withResult(mixed $result): self
+ {
+ return new self(
+ info: $this->info,
+ result: $result,
+ status: $this->status,
+ );
+ }
+}
diff --git a/src/Test/TestRunner.php b/src/Test/TestRunner.php
new file mode 100644
index 0000000..2e8460b
--- /dev/null
+++ b/src/Test/TestRunner.php
@@ -0,0 +1,66 @@
+prepareInterceptors($info);
+ return Pipeline::prepare(...$interceptors)->with(
+ static function (TestInfo $info): TestResult {
+ # TODO resolve arguments
+ $instance = $info->caseInfo->instance;
+ $result = $instance === null
+ ? $info->testDefinition->reflection->invoke()
+ : $info->testDefinition->reflection->invoke($instance);
+
+ return new TestResult(
+ $info,
+ $result,
+ Status::Passed,
+ );
+ },
+ /** @see TestCallInterceptor::runTest() */
+ 'runTest',
+ )($info);
+ }
+
+ /**
+ * @return list
+ */
+ private function prepareInterceptors(TestInfo $info): array
+ {
+ $classAttributes = $info->caseInfo->definition->reflection?->getAttributes(
+ Interceptable::class,
+ \ReflectionAttribute::IS_INSTANCEOF,
+ ) ?? [];
+ $methodAttributes = $info->testDefinition->reflection->getAttributes(
+ Interceptable::class,
+ \ReflectionAttribute::IS_INSTANCEOF,
+ );
+
+ # Merge and instantiate attributes
+ $attrs = \array_map(
+ static fn(\ReflectionAttribute $a): Interceptable => $a->newInstance(),
+ \array_merge($classAttributes, $methodAttributes),
+ );
+
+ return $this->interceptorProvider->fromAttributes(TestCallInterceptor::class, ...$attrs);
+ }
+}
diff --git a/src/Test/TestsProvider.php b/src/Test/TestsProvider.php
new file mode 100644
index 0000000..cbf1709
--- /dev/null
+++ b/src/Test/TestsProvider.php
@@ -0,0 +1,40 @@
+
+ */
+ public function getTests(): iterable
+ {
+ yield from [];
+ }
+
+ /**
+ * Gets test case definitions with applied filter.
+ *
+ * @return iterable
+ */
+ public function getCases(): iterable
+ {
+ yield from [];
+ }
+}
diff --git a/tests/Fixture/TestInterceptors.php b/tests/Fixture/TestInterceptors.php
new file mode 100644
index 0000000..6778f38
--- /dev/null
+++ b/tests/Fixture/TestInterceptors.php
@@ -0,0 +1,20 @@
+ ['C:/Users/test', true];
+ yield 'windows drive letter' => ['C:', true];
+ yield 'windows relative path' => ['Users/test', false];
+ yield 'windows implicit relative' => ['./test', false];
+ yield 'unix absolute path' => ['/home/user', true];
+ yield 'unix relative path' => ['home/user', false];
+ yield 'unix implicit relative' => ['./test', false];
+ yield 'dot path' => ['.', false];
+ yield 'double dot path' => ['..', false];
+ }
+
+ public static function providePathsForParent(): \Generator
+ {
+ yield ['.', '..'];
+ yield ['..', '../..'];
+ yield ['path/to/..', '.'];
+ yield ['/home', '/.'];
+ yield ['C:/Users', 'C:/.'];
+ yield ['C:/.', 'C:/.'];
+ yield ['filename.txt', '.'];
+ yield ['some/path/file.txt', 'some/path'];
+ }
+
+ public function testCreateReturnsPathInstance(): void
+ {
+ // Arrange & Act
+ $path = Path::create('test/path');
+
+ // Assert
+ self::assertInstanceOf(Path::class, $path);
+ }
+
+ public function testCreateWithEmptyPathReturnsCurrentDirectory(): void
+ {
+ // Arrange & Act
+ $path = Path::create('');
+
+ // Assert
+ self::assertSame('.', (string) $path);
+ }
+
+ public function testCreateNormalizesDirectorySeparators(): void
+ {
+ // Arrange & Act
+ $path = Path::create('test\\path/mixed/separators\\here');
+
+ // Assert
+ self::assertSame('test/path/mixed/separators/here', (string) $path);
+ }
+
+ public function testCreateRemovesMultipleSeparators(): void
+ {
+ // Arrange & Act
+ $path = Path::create('test//path///extra//separators');
+
+ // Assert
+ self::assertSame('test/path/extra/separators', (string) $path);
+ }
+
+ public function testCreateResolvesCurrentDirectorySegments(): void
+ {
+ // Arrange & Act
+ $path = Path::create('test/./path/./current');
+
+ // Assert
+ self::assertSame('test/path/current', (string) $path);
+ }
+
+ public function testCreateResolvesParentDirectorySegments(): void
+ {
+ // Arrange & Act
+ $path = Path::create('test/parent/../path');
+
+ // Assert
+ self::assertSame('test/path', (string) $path);
+ }
+
+ public function testCreateThrowsExceptionForInvalidParentNavigation(): void
+ {
+ // Arrange & Assert
+ $this->expectException(\LogicException::class);
+ $this->expectExceptionMessage('Cannot go up from root');
+
+ // Act
+ Path::create('/test/../..');
+ }
+
+ public function testJoinPathComponents(): void
+ {
+ // Arrange
+ $path = Path::create('base/path');
+
+ // Act
+ $result = $path->join('additional', 'components');
+
+ // Assert
+ self::assertSame('base/path/additional/components', (string) $result);
+ }
+
+ public function testJoinWithEmptyComponentsIgnoresThem(): void
+ {
+ // Arrange
+ $path = Path::create('base/path');
+
+ // Act
+ $result = $path->join('', 'component', '');
+
+ // Assert
+ self::assertSame('base/path/component', (string) $result);
+ }
+
+ public function testJoinWithPathObjects(): void
+ {
+ // Arrange
+ $path = Path::create('base/path');
+ $additionalPath = Path::create('additional/path');
+
+ // Assert (prepare for expected exception)
+ $this->expectException(\LogicException::class);
+ $this->expectExceptionMessage('Joining an absolute path is not allowed');
+
+ // Act
+ // Using an absolute Path object which should throw
+ $path->join($additionalPath->absolute());
+ }
+
+ public function testJoinWithRelativePathObjects(): void
+ {
+ // Arrange
+ $path = Path::create('base/path');
+ $additionalPath = Path::create('additional/path');
+
+ // Act
+ $result = $path->join($additionalPath);
+
+ // Assert
+ self::assertSame('base/path/additional/path', (string) $result);
+ }
+
+ public function testJoinWithAbsolutePathString(): void
+ {
+ // Arrange
+ $path = Path::create('base/path');
+
+ // Assert (prepare for expected exception)
+ $this->expectException(\LogicException::class);
+ $this->expectExceptionMessage('Joining an absolute path is not allowed');
+
+ // Act
+ $path->join('/absolute/path');
+ }
+
+ public function testName(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/file.txt');
+
+ // Act
+ $name = $path->name();
+
+ // Assert
+ self::assertSame('file.txt', $name);
+ }
+
+ public function testNameWithNoDirectoryComponents(): void
+ {
+ // Arrange
+ $path = Path::create('file.txt');
+
+ // Act
+ $name = $path->name();
+
+ // Assert
+ self::assertSame('file.txt', $name);
+ }
+
+ public function testStem(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/file.txt');
+
+ // Act
+ $stem = $path->stem();
+
+ // Assert
+ self::assertSame('file', $stem);
+ }
+
+ public function testStemWithNoExtension(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/file');
+
+ // Act
+ $stem = $path->stem();
+
+ // Assert
+ self::assertSame('file', $stem);
+ }
+
+ public function testStemWithMultipleDots(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/file.config.json');
+
+ // Act
+ $stem = $path->stem();
+
+ // Assert
+ self::assertSame('file.config', $stem);
+ }
+
+ public function testStemWithHiddenFile(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/.hidden');
+
+ // Act
+ $stem = $path->stem();
+
+ // Assert
+ self::assertSame('.hidden', $stem);
+ }
+
+ public function testExtension(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/file.txt');
+
+ // Act
+ $extension = $path->extension();
+
+ // Assert
+ self::assertSame('txt', $extension);
+ }
+
+ public function testExtensionWithMultipleDots(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/file.config.json');
+
+ // Act
+ $extension = $path->extension();
+
+ // Assert
+ self::assertSame('json', $extension);
+ }
+
+ public function testExtensionWithNoExtension(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/file');
+
+ // Act
+ $extension = $path->extension();
+
+ // Assert
+ self::assertSame('', $extension);
+ }
+
+ public function testExtensionWithHiddenFile(): void
+ {
+ // Arrange
+ $path = Path::create('some/path/.hidden');
+
+ // Act
+ $extension = $path->extension();
+
+ // Assert
+ self::assertSame('hidden', $extension);
+ }
+
+ #[DataProvider('providePathsForParent')]
+ public function testParent(string $inputPath, string $expectedParent): void
+ {
+ // Arrange
+ $path = Path::create($inputPath);
+
+ // Act
+ $parent = $path->parent();
+
+ // Assert
+ self::assertSame($expectedParent, (string) $parent);
+ }
+
+ #[DataProvider('providePathsForAbsoluteDetection')]
+ public function testIsAbsolute(string $pathString, bool $expected): void
+ {
+ // Arrange
+ $path = Path::create($pathString);
+
+ // Act
+ $isAbsolute = $path->isAbsolute();
+
+ // Assert
+ self::assertSame($expected, $isAbsolute, "Path '$pathString' should be " . ($expected ? 'absolute' : 'relative'));
+ }
+
+ public function testIsRelative(): void
+ {
+ // Arrange
+ $absolutePath = DIRECTORY_SEPARATOR === '\\'
+ ? Path::create('C:/Users/test')
+ : Path::create('/home/user');
+
+ $relativePath = Path::create('relative/path');
+
+ // Act & Assert
+ self::assertFalse($absolutePath->isRelative());
+ self::assertTrue($relativePath->isRelative());
+ }
+
+ /**
+ * This test uses real filesystem access to check if a path exists.
+ * It creates a temporary file and checks its existence.
+ */
+ public function testExists(): void
+ {
+ // Arrange
+ $tempFile = \tempnam(\sys_get_temp_dir(), 'path_test_');
+ self::assertIsString($tempFile, 'Failed to create temp file');
+
+ $path = Path::create($tempFile);
+ $nonExistingPath = Path::create('non/existing/path/file.txt');
+
+ // Act & Assert
+ try {
+ self::assertTrue($path->exists());
+ self::assertFalse($nonExistingPath->exists());
+ } finally {
+ // Clean up
+ @\unlink($tempFile);
+ }
+ }
+
+ /**
+ * Note: This test might have limitations depending on the environment.
+ * It checks the expected behavior of isDir without requiring an actual directory to exist.
+ */
+ public function testIsDir(): void
+ {
+ // Arrange
+ $currentDirPath = Path::create('.');
+ $parentDirPath = Path::create('..');
+ $filePath = Path::create('file.txt');
+
+ // Act & Assert
+ self::assertTrue($currentDirPath->isDir());
+ self::assertTrue($parentDirPath->isDir());
+ self::assertFalse($filePath->isDir());
+ }
+
+ /**
+ * Note: This test might have limitations depending on the environment.
+ * It checks the expected behavior of isFile without requiring an actual file to exist.
+ */
+ public function testIsFile(): void
+ {
+ // Arrange
+ $currentDirPath = Path::create('.');
+ $parentDirPath = Path::create('..');
+ $filePath = Path::create('file.txt');
+
+ // Create a temporary file to test with
+ $tempFile = \tempnam(\sys_get_temp_dir(), 'path_test_');
+ self::assertIsString($tempFile, 'Failed to create temp file');
+ $realFilePath = Path::create($tempFile);
+
+ // Act & Assert
+ try {
+ self::assertFalse($currentDirPath->isFile());
+ self::assertFalse($parentDirPath->isFile());
+ self::assertFalse($filePath->isFile()); // Doesn't exist yet
+ self::assertTrue($realFilePath->isFile(), "Temporary file should be a file `$realFilePath`");
+ } finally {
+ // Clean up
+ @\unlink($tempFile);
+ }
+ }
+
+ public function testAbsoluteForAlreadyAbsolutePath(): void
+ {
+ // Arrange
+ $absolutePath = DIRECTORY_SEPARATOR === '\\'
+ ? Path::create('C:/Users/test')
+ : Path::create('/home/user');
+
+ // Act
+ $result = $absolutePath->absolute();
+
+ // Assert
+ self::assertSame((string) $absolutePath, (string) $result);
+ }
+
+ public function testAbsoluteForRelativePath(): void
+ {
+ // Arrange
+ $relativePath = Path::create('relative/path');
+
+ // Skip this test if we can't get cwd
+ $cwd = \getcwd();
+ if ($cwd === false) {
+ self::markTestSkipped('Cannot get current working directory');
+ }
+
+ $expected = Path::create($cwd . DIRECTORY_SEPARATOR . 'relative/path');
+
+ // Act
+ $result = $relativePath->absolute();
+
+ // Assert
+ self::assertSame((string) $expected, (string) $result);
+ }
+
+ public function testCreateWindowsTmpFile(): void
+ {
+ $path = Path::create('C:\Users\roxbl\AppData\Local\Temp\patB6E7.tmp');
+
+ self::assertSame('C:/Users/roxbl/AppData/Local/Temp/patB6E7.tmp', (string) $path);
+ }
+
+ public function testToString(): void
+ {
+ // Arrange
+ $pathString = 'some/path/file.txt';
+ $path = Path::create($pathString);
+
+ // Act
+ $result = (string) $path;
+
+ // Assert
+ self::assertSame('some/path/file.txt', $result);
+ }
+}
diff --git a/tests/Unit/Test/TestsRunnerTest.php b/tests/Unit/Test/TestsRunnerTest.php
new file mode 100644
index 0000000..2242198
--- /dev/null
+++ b/tests/Unit/Test/TestsRunnerTest.php
@@ -0,0 +1,68 @@
+runTest($info);
+
+ self::assertSame(3, $result->result);
+ self::assertSame(Status::Flaky, $result->status);
+ }
+
+ /**
+ * @see \Tests\Fixture\withRetryPolicy()
+ */
+ public function testRunFunctionWithRetry(): void
+ {
+ $instance = self::createInstance();
+ $info = new TestInfo(
+ caseInfo: new CaseInfo(
+ instance: new TestInterceptors(),
+ ),
+ testDefinition: new TestDefinition(
+ reflection: new \ReflectionFunction(\Tests\Fixture\withRetryPolicy(...)),
+ ),
+ );
+
+ $result = $instance->runTest($info);
+
+ self::assertSame(3, $result->result);
+ self::assertSame(Status::Passed, $result->status);
+ }
+
+ private static function createInstance(): TestRunner
+ {
+ return new TestRunner(InterceptorProvider::createDefault());
+ }
+}