feat: complete TaskerBridges with Symfony, Laravel, and Native adapters
Some checks are pending
CI / tasker-bridges (8.2) (push) Waiting to run
CI / tasker-bridges (8.3) (push) Waiting to run

This commit is contained in:
Funky Waddle 2026-02-22 03:57:50 -06:00
parent eead487714
commit da1efaba55
23 changed files with 1421 additions and 14 deletions

32
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,32 @@
name: CI
on:
push:
branches: [ main, master ]
pull_request:
jobs:
tasker-bridges:
runs-on: ubuntu-latest
defaults:
run:
working-directory: TaskerBridges
strategy:
matrix:
php: ['8.2', '8.3']
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
tools: phpstan, php-cs-fixer, phpunit
- name: Validate composer.json
run: php -v && composer validate --strict
- name: Install dependencies
run: composer install --no-interaction --no-progress --prefer-dist
- name: PHPUnit
run: vendor/bin/phpunit --configuration phpunit.xml --display-deprecations
- name: PHPStan
run: vendor/bin/phpstan analyse --no-progress --memory-limit=512M

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.idea/
/vendor/
/composer.lock
/.phpunit.cache/
/phpstan-baseline.neon

View file

@ -3,25 +3,25 @@
This document defines the implementation milestones for the `getphred/tasker-bridges` package.
## 1. Bridge Infrastructure
- [ ] Initialize repository with `getphred/console-contracts` dependency.
- [ ] Set up testing environment for multi-toolkit validation (mocking Symfony/Laravel).
- [x] Initialize repository with `getphred/console-contracts` dependency.
- [x] Set up testing environment for multi-toolkit validation (mocking Symfony/Laravel).
## 2. Symfony Bridge
- [ ] Implement `SymfonyCommandAdapter`.
- [ ] Implement `SymfonyInputAdapter` and `SymfonyOutputAdapter`.
- [ ] Implement helper adapters (Interaction, ProgressBar, Table).
- [ ] Implement fixed markup translation (mapping to Symfony Formatter).
- [x] Implement `SymfonyCommandAdapter`.
- [x] Implement `SymfonyInputAdapter` and `SymfonyOutputAdapter`.
- [x] Implement helper adapters (Interaction, ProgressBar, Table).
- [x] Implement fixed markup translation (mapping to Symfony Formatter).
## 3. Laravel Bridge
- [ ] Implement `LaravelCommandAdapter` and `LaravelServiceProvider`.
- [ ] Integrate Symfony IO adapters within the Laravel context.
- [ ] Implement native Laravel interaction helper mappings.
- [x] Implement `LaravelCommandAdapter` and `LaravelServiceProvider`.
- [x] Integrate Symfony IO adapters within the Laravel context.
- [x] Implement native Laravel interaction helper mappings.
## 4. Phred (Native) Bridge
- [ ] Implement ANSI-based `OutputInterface` with full markup support.
- [ ] Implement lightweight interaction and progress helpers.
- [ ] Implement regex-based Markdown-to-Markup converter.
- [x] Implement ANSI-based `OutputInterface` with full markup support.
- [x] Implement lightweight interaction and progress helpers.
- [x] Implement regex-based Markdown-to-Markup converter.
## 5. Universal Integration
- [ ] Implement universal exit code translation logic.
- [ ] Implement global flag mapping (Verbosity, Decoration, Interactivity).
- [x] Implement universal exit code translation logic.
- [x] Implement global flag mapping (Verbosity, Decoration, Interactivity).

View file

@ -7,6 +7,19 @@
"php": "^8.2",
"getphred/console-contracts": "dev-master"
},
"require-dev": {
"phpunit/phpunit": "^10.0",
"phpstan/phpstan": "^1.10",
"symfony/console": "^6.0 || ^7.0",
"illuminate/console": "^10.0 || ^11.0",
"illuminate/support": "^10.0 || ^11.0"
},
"repositories": [
{
"type": "path",
"url": "../ConsoleContracts"
}
],
"suggest": {
"getphred/tasker": "To use the PhredBridge with the Tasker runner",
"symfony/console": "To use the SymfonyBridge",

5
phpstan.neon Normal file
View file

@ -0,0 +1,5 @@
parameters:
level: 8
paths:
- src
treatPhpDocTypesAsCertain: false

24
phpunit.xml Normal file
View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="Phred TaskerBridges Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<coverage/>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges;
use Phred\ConsoleContracts\ExitCode;
use Throwable;
use InvalidArgumentException;
use RuntimeException;
use LogicException;
/**
* Translates exceptions and codes into Phred standardized exit codes.
*/
final class ExitCodeTranslator
{
/**
* Translates an exception to an exit code.
*
* @param Throwable $throwable
* @return int
*/
public static function translate(Throwable $throwable): int
{
if ($throwable instanceof InvalidArgumentException) {
return ExitCode::USAGE;
}
if ($throwable instanceof RuntimeException || $throwable instanceof LogicException) {
return ExitCode::SOFTWARE;
}
// Default to generic failure
return ExitCode::FAILURE;
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges\Laravel;
use Illuminate\Console\Command as LaravelCommand;
use Phred\ConsoleContracts\CommandInterface;
use Phred\TaskerBridges\Symfony\SymfonyInputAdapter;
use Phred\TaskerBridges\Symfony\SymfonyOutputAdapter;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
/**
* Adapter to run Phred commands within a Laravel Artisan application.
*/
class LaravelCommandAdapter extends LaravelCommand
{
public function __construct(private readonly CommandInterface $phredCommand)
{
$this->name = $phredCommand->getName();
$this->description = $phredCommand->getDescription();
parent::__construct();
}
protected function configure(): void
{
foreach ($this->phredCommand->getArguments() as $name => $description) {
$this->addArgument($name, InputArgument::OPTIONAL, $description);
}
foreach ($this->phredCommand->getOptions() as $name => $description) {
$this->addOption($name, null, InputOption::VALUE_OPTIONAL, $description);
}
}
public function handle(): int
{
$phredInput = new SymfonyInputAdapter($this->input);
$phredOutput = new SymfonyOutputAdapter($this->output);
return $this->phredCommand->execute($phredInput, $phredOutput);
}
}

View file

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges\Laravel;
use Illuminate\Support\ServiceProvider;
use Phred\ConsoleContracts\CommandInterface;
/**
* Service provider to automatically discover and register Phred commands in Laravel.
*/
class LaravelServiceProvider extends ServiceProvider
{
public function boot(): void
{
if (!$this->app->runningInConsole()) {
return;
}
$this->discoverCommands();
}
private function discoverCommands(): void
{
$basePath = function_exists('base_path') ? base_path() : getcwd();
// 1. Discover via composer.json (package-based/explicitly registered)
$this->discoverFromComposer($basePath);
// 2. Discover via app/Console/Commands (Laravel standard)
$this->discoverFromDirectory($basePath . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'Console' . DIRECTORY_SEPARATOR . 'Commands');
}
private function discoverFromComposer(string $basePath): void
{
$rootComposer = $basePath . DIRECTORY_SEPARATOR . 'composer.json';
if (!file_exists($rootComposer)) {
return;
}
$json = file_get_contents($rootComposer);
if ($json === false) {
return;
}
$composerData = json_decode($json, true);
if (!is_array($composerData)) {
return;
}
$commands = $composerData['extra']['phred-tasker']['commands'] ?? [];
$adapters = [];
foreach ((array)$commands as $commandClass) {
if (is_string($commandClass) && is_subclass_of($commandClass, CommandInterface::class)) {
$adapters[] = new LaravelCommandAdapter($this->app->make($commandClass));
}
}
if (!empty($adapters)) {
$this->commands($adapters);
}
}
private function discoverFromDirectory(string $path): void
{
if (!is_dir($path)) {
return;
}
$adapters = [];
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path));
foreach ($files as $file) {
if (!$file->isFile() || $file->getExtension() !== 'php') {
continue;
}
$className = $this->resolveClassName($file->getPathname());
if ($className && is_subclass_of($className, CommandInterface::class)) {
$adapters[] = new LaravelCommandAdapter($this->app->make($className));
}
}
if (!empty($adapters)) {
$this->commands($adapters);
}
}
private function resolveClassName(string $path): ?string
{
$content = file_get_contents($path);
if ($content === false) {
return null;
}
$tokens = token_get_all($content);
$namespace = '';
$class = '';
$gettingNamespace = false;
$gettingClass = false;
foreach ($tokens as $index => $token) {
if (is_array($token)) {
if ($token[0] === T_NAMESPACE) {
$gettingNamespace = true;
} elseif ($token[0] === T_CLASS) {
$gettingClass = true;
} elseif ($gettingNamespace) {
if ($token[0] === T_STRING || $token[0] === T_NAME_QUALIFIED) {
$namespace .= $token[1];
} elseif ($token[1] === ';') {
$gettingNamespace = false;
}
} elseif ($gettingClass && $token[0] === T_STRING) {
$class = $token[1];
break;
}
}
}
return $namespace ? $namespace . '\\' . $class : $class;
}
}

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges\Phred;
use Phred\ConsoleContracts\InteractionInterface;
/**
* Native Phred adapter for InteractionInterface.
*/
class InteractionAdapter implements InteractionInterface
{
/**
* @var resource
*/
private $inputStream;
/**
* @param resource|null $inputStream
*/
public function __construct($inputStream = null)
{
$this->inputStream = $inputStream ?? STDIN;
}
/**
* @inheritDoc
*/
public function ask(string $question, ?string $default = null): string
{
$prompt = $question . ($default ? " [$default]" : '') . ': ';
echo $prompt;
$input = trim(fgets($this->inputStream) ?: '');
if ($input === '' && $default !== null) {
return $default;
}
return $input;
}
/**
* @inheritDoc
*/
public function confirm(string $question, bool $default = true): bool
{
$prompt = $question . ($default ? ' [Y/n]' : ' [y/N]') . ': ';
echo $prompt;
$input = strtolower(trim(fgets($this->inputStream) ?: ''));
if ($input === '') {
return $default;
}
return $input === 'y' || $input === 'yes';
}
/**
* @inheritDoc
*/
public function secret(string $question): string
{
echo $question . ': ';
// Hide input using stty if available
$sttyAvailable = shell_exec('stty 2>&1') !== null;
if ($sttyAvailable) {
shell_exec('stty -echo');
}
$input = trim(fgets($this->inputStream) ?: '');
if ($sttyAvailable) {
shell_exec('stty echo');
echo PHP_EOL;
}
return $input;
}
/**
* @inheritDoc
*/
public function choice(string $question, array $choices, mixed $default = null): mixed
{
echo $question . ':' . PHP_EOL;
foreach ($choices as $key => $choice) {
echo " [$key] $choice" . PHP_EOL;
}
$input = $this->ask('Choice', (string)$default);
return $choices[$input] ?? $default;
}
}

View file

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges\Phred;
use Phred\ConsoleContracts\Helpers\MarkdownConverterInterface;
use Phred\ConsoleContracts\OutputInterface;
/**
* Native Phred adapter for MarkdownConverterInterface.
*/
class MarkdownConverter implements MarkdownConverterInterface
{
public function __construct(private readonly OutputInterface $output)
{
}
/**
* @inheritDoc
*/
public function convert(string $markdown): string
{
$result = $markdown;
// Headers
$result = (string)preg_replace('/^# (.*)$/m', '<info><b>$1</b></info>', $result);
$result = (string)preg_replace('/^## (.*)$/m', '<info>$1</info>', $result);
$result = (string)preg_replace('/^### (.*)$/m', '<b>$1</b>', $result);
// Bold
$result = (string)preg_replace('/\*\*(.*?)\*\*/', '<b>$1</b>', $result);
$result = (string)preg_replace('/__(.*?)__/', '<b>$1</b>', $result);
// Italic
$result = (string)preg_replace('/\*(.*?)\*/', '<i>$1</i>', $result);
$result = (string)preg_replace('/_(.*?)_/', '<i>$1</i>', $result);
// Lists
$result = (string)preg_replace('/^[-*] (.*)$/m', ' • $1', $result);
// Code blocks
$result = (string)preg_replace('/```(.*?)```/s', '<comment>$1</comment>', $result);
$result = (string)preg_replace('/`(.*?)`/', '<comment>$1</comment>', $result);
// Tables
$result = $this->parseTables($result);
return $result;
}
private function parseTables(string $markdown): string
{
$lines = explode(PHP_EOL, $markdown);
$result = [];
$tableLines = [];
$inTable = false;
foreach ($lines as $line) {
$isSeparator = (bool)preg_match('/^\|?[:\s-]*\|[:\s-]*\|?/', trim($line));
$isRow = (bool)preg_match('/^\|.*\|$/', trim($line));
if ($isRow || ($inTable && $isSeparator)) {
$inTable = true;
$tableLines[] = $line;
} else {
if ($inTable) {
$result[] = $this->renderTable($tableLines);
$tableLines = [];
$inTable = false;
}
$result[] = $line;
}
}
if ($inTable) {
$result[] = $this->renderTable($tableLines);
}
return implode(PHP_EOL, $result);
}
/**
* @param array<string> $lines
*/
private function renderTable(array $lines): string
{
if (count($lines) < 2) {
return implode(PHP_EOL, $lines);
}
$headers = [];
$rows = [];
$alignments = [];
$headerLine = array_shift($lines);
$separatorLine = array_shift($lines);
// Parse Headers
$headers = array_map('trim', explode('|', trim($headerLine, '|')));
// Parse Alignments
if ($separatorLine !== null) {
$parts = explode('|', trim($separatorLine, '|'));
foreach ($parts as $index => $part) {
$part = trim($part);
if (str_starts_with($part, ':') && str_ends_with($part, ':')) {
$alignments[$index] = 'center';
} elseif (str_ends_with($part, ':')) {
$alignments[$index] = 'right';
} else {
$alignments[$index] = 'left';
}
}
}
// Parse Rows
foreach ($lines as $line) {
$rows[] = array_map('trim', explode('|', trim($line, '|')));
}
// Use TableAdapter to render
$table = new TableAdapter($this->output);
$table->setHeaders($headers);
$table->setColumnAlignments($alignments);
foreach ($rows as $row) {
$table->addRow($row);
}
ob_start();
$table->render();
return trim((string)ob_get_clean());
}
/**
* @inheritDoc
*/
public function convertFile(string $path): string
{
if (!file_exists($path)) {
return '';
}
$content = file_get_contents($path);
if ($content === false) {
return '';
}
return $this->convert($content);
}
}

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges\Phred;
use Phred\ConsoleContracts\Helpers\ProgressBarInterface;
use Phred\ConsoleContracts\OutputInterface;
/**
* Native Phred adapter for ProgressBarInterface.
*/
class ProgressBarAdapter implements ProgressBarInterface
{
private int $max = 0;
private int $current = 0;
public function __construct(private readonly OutputInterface $output)
{
}
/**
* @inheritDoc
*/
public function start(int $max = 0): void
{
$this->max = $max;
$this->current = 0;
$this->render();
}
/**
* @inheritDoc
*/
public function advance(int $step = 1): void
{
$this->current += $step;
if ($this->max > 0 && $this->current > $this->max) {
$this->current = $this->max;
}
$this->render();
}
/**
* @inheritDoc
*/
public function finish(): void
{
if ($this->max > 0) {
$this->current = $this->max;
}
$this->render();
$this->output->writeln('');
}
private function render(): void
{
$percent = $this->max > 0 ? ($this->current / $this->max) * 100 : 0;
$barWidth = 40;
$filledWidth = $this->max > 0 ? (int)round(($this->current / $this->max) * $barWidth) : 0;
$bar = str_repeat('█', $filledWidth) . str_repeat('_', $barWidth - $filledWidth);
$message = sprintf(
"\r [%s] %d%% (%d/%d)",
$bar,
$percent,
$this->current,
$this->max
);
$this->output->write($message);
}
}

137
src/Phred/TableAdapter.php Normal file
View file

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges\Phred;
use Phred\ConsoleContracts\Helpers\TableInterface;
use Phred\ConsoleContracts\OutputInterface;
/**
* Native Phred adapter for TableInterface.
*/
class TableAdapter implements TableInterface
{
/**
* @var array<string>
*/
private array $headers = [];
/**
* @var array<array<mixed>>
*/
private array $rows = [];
/**
* @var array<int, string>
*/
private array $alignments = [];
public function __construct(private readonly OutputInterface $output)
{
}
/**
* @inheritDoc
*/
public function setHeaders(array $headers): void
{
$this->headers = $headers;
}
/**
* @inheritDoc
*/
public function addRow(array $row): void
{
$this->rows[] = $row;
}
/**
* @inheritDoc
*/
public function setColumnAlignments(array $alignments): void
{
$this->alignments = $alignments;
}
/**
* @inheritDoc
*/
public function render(): void
{
if (empty($this->headers) && empty($this->rows)) {
return;
}
$columnWidths = $this->calculateColumnWidths();
// Render headers
if (!empty($this->headers)) {
$this->renderRow($this->headers, $columnWidths);
$this->renderSeparator($columnWidths);
}
// Render rows
foreach ($this->rows as $row) {
$this->renderRow($row, $columnWidths);
}
}
/**
* @return array<int, int>
*/
private function calculateColumnWidths(): array
{
$widths = [];
foreach ($this->headers as $index => $header) {
$widths[$index] = mb_strlen((string)$header);
}
foreach ($this->rows as $row) {
foreach ($row as $index => $value) {
$width = mb_strlen((string)$value);
if (!isset($widths[$index]) || $width > $widths[$index]) {
$widths[$index] = $width;
}
}
}
return $widths;
}
/**
* @param array<mixed> $row
* @param array<int, int> $widths
*/
private function renderRow(array $row, array $widths): void
{
$formatted = '|';
foreach ($widths as $index => $width) {
$value = (string)($row[$index] ?? '');
$alignment = $this->alignments[$index] ?? 'left';
$padType = match ($alignment) {
'right' => STR_PAD_LEFT,
'center' => STR_PAD_BOTH,
default => STR_PAD_RIGHT,
};
$formatted .= ' ' . str_pad($value, $width, ' ', $padType) . ' |';
}
$this->output->writeln($formatted);
}
/**
* @param array<int, int> $widths
*/
private function renderSeparator(array $widths): void
{
$separator = '+';
foreach ($widths as $width) {
$separator .= str_repeat('-', $width + 2) . '+';
}
$this->output->writeln($separator);
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges\Symfony;
use Phred\ConsoleContracts\CommandInterface;
use Symfony\Component\Console\Command\Command as SymfonyCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface as SymfonyInputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface as SymfonyOutputInterface;
/**
* Adapter to run Phred commands within a Symfony Console application.
*/
class SymfonyCommandAdapter extends SymfonyCommand
{
public function __construct(private readonly CommandInterface $phredCommand)
{
parent::__construct($phredCommand->getName());
}
protected function configure(): void
{
$this->setDescription($this->phredCommand->getDescription());
foreach ($this->phredCommand->getArguments() as $name => $description) {
$this->addArgument($name, InputArgument::OPTIONAL, $description);
}
foreach ($this->phredCommand->getOptions() as $name => $description) {
$this->addOption($name, null, InputOption::VALUE_OPTIONAL, $description);
}
}
protected function execute(SymfonyInputInterface $input, SymfonyOutputInterface $output): int
{
$phredInput = new SymfonyInputAdapter($input);
$phredOutput = new SymfonyOutputAdapter($output);
return $this->phredCommand->execute($phredInput, $phredOutput);
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges\Symfony;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Helper to register Phred tags in Symfony OutputFormatter.
*/
final class SymfonyFormatter
{
/**
* Registers Phred tags in the given output formatter.
*
* @param OutputInterface $output
* @return void
*/
public static function register(OutputInterface $output): void
{
$formatter = $output->getFormatter();
if (!$formatter->hasStyle('success')) {
$formatter->setStyle('success', new OutputFormatterStyle('green'));
}
if (!$formatter->hasStyle('info')) {
$formatter->setStyle('info', new OutputFormatterStyle('blue'));
}
if (!$formatter->hasStyle('warning')) {
$formatter->setStyle('warning', new OutputFormatterStyle('black', 'yellow'));
}
// Symfony already has <error> and <comment> by default
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges\Symfony;
use Phred\ConsoleContracts\InputInterface;
use Symfony\Component\Console\Input\InputInterface as SymfonyInputInterface;
/**
* Adapter for Symfony InputInterface.
*/
class SymfonyInputAdapter implements InputInterface
{
public function __construct(private readonly SymfonyInputInterface $symfonyInput)
{
}
/**
* @inheritDoc
*/
public function getArgument(string $name, mixed $default = null): mixed
{
return $this->symfonyInput->getArgument($name) ?? $default;
}
/**
* @inheritDoc
*/
public function getOption(string $name, mixed $default = null): mixed
{
return $this->symfonyInput->getOption($name) ?? $default;
}
/**
* @inheritDoc
*/
public function hasOption(string $name): bool
{
return $this->symfonyInput->hasOption($name);
}
}

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges\Symfony;
use Phred\ConsoleContracts\InteractionInterface;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface as SymfonyInputInterface;
use Symfony\Component\Console\Output\OutputInterface as SymfonyOutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
/**
* Adapter for Symfony InteractionInterface using QuestionHelper.
*/
class SymfonyInteractionAdapter implements InteractionInterface
{
public function __construct(
private readonly SymfonyInputInterface $input,
private readonly SymfonyOutputInterface $output,
private readonly QuestionHelper $helper
) {
}
/**
* @inheritDoc
*/
public function ask(string $question, ?string $default = null): string
{
$q = new Question($question, $default);
return (string)$this->helper->ask($this->input, $this->output, $q);
}
/**
* @inheritDoc
*/
public function confirm(string $question, bool $default = true): bool
{
$q = new ConfirmationQuestion($question, $default);
return (bool)$this->helper->ask($this->input, $this->output, $q);
}
/**
* @inheritDoc
*/
public function secret(string $question): string
{
$q = new Question($question);
$q->setHidden(true);
$q->setHiddenFallback(true);
return (string)$this->helper->ask($this->input, $this->output, $q);
}
/**
* @inheritDoc
*/
public function choice(string $question, array $choices, mixed $default = null): mixed
{
$q = new ChoiceQuestion($question, $choices, $default);
return $this->helper->ask($this->input, $this->output, $q);
}
}

View file

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges\Symfony;
use Phred\ConsoleContracts\OutputInterface;
use Symfony\Component\Console\Output\OutputInterface as SymfonyOutputInterface;
/**
* Adapter for Symfony OutputInterface.
*/
class SymfonyOutputAdapter implements OutputInterface
{
public function __construct(private readonly SymfonyOutputInterface $symfonyOutput)
{
SymfonyFormatter::register($this->symfonyOutput);
}
/**
* @inheritDoc
*/
public function write(string $message): void
{
$this->symfonyOutput->write($message);
}
/**
* @inheritDoc
*/
public function writeln(string $message): void
{
$this->symfonyOutput->writeln($message);
}
/**
* @inheritDoc
*/
public function success(string $message): void
{
$this->writeln("<success>$message</success>");
}
/**
* @inheritDoc
*/
public function info(string $message): void
{
$this->writeln("<info>$message</info>");
}
/**
* @inheritDoc
*/
public function error(string $message): void
{
$this->writeln("<error>$message</error>");
}
/**
* @inheritDoc
*/
public function warning(string $message): void
{
$this->writeln("<warning>$message</warning>");
}
/**
* @inheritDoc
*/
public function comment(string $message): void
{
$this->writeln("<comment>$message</comment>");
}
/**
* @inheritDoc
*/
public function setVerbosity(int $level): void
{
// Map Phred verbosity to Symfony verbosity
$symfonyLevel = match ($level) {
0 => SymfonyOutputInterface::VERBOSITY_QUIET,
1 => SymfonyOutputInterface::VERBOSITY_NORMAL,
2 => SymfonyOutputInterface::VERBOSITY_VERBOSE,
3 => SymfonyOutputInterface::VERBOSITY_VERY_VERBOSE,
4 => SymfonyOutputInterface::VERBOSITY_DEBUG,
default => SymfonyOutputInterface::VERBOSITY_NORMAL,
};
$this->symfonyOutput->setVerbosity($symfonyLevel);
}
/**
* @inheritDoc
*/
public function getVerbosity(): int
{
$symfonyLevel = $this->symfonyOutput->getVerbosity();
return match ($symfonyLevel) {
SymfonyOutputInterface::VERBOSITY_QUIET => 0,
SymfonyOutputInterface::VERBOSITY_NORMAL => 1,
SymfonyOutputInterface::VERBOSITY_VERBOSE => 2,
SymfonyOutputInterface::VERBOSITY_VERY_VERBOSE => 3,
SymfonyOutputInterface::VERBOSITY_DEBUG => 4,
default => 1,
};
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges\Symfony;
use Phred\ConsoleContracts\Helpers\ProgressBarInterface;
use Symfony\Component\Console\Helper\ProgressBar;
/**
* Adapter for Symfony ProgressBar.
*/
class SymfonyProgressBarAdapter implements ProgressBarInterface
{
public function __construct(private readonly ProgressBar $progressBar)
{
}
/**
* @inheritDoc
*/
public function start(int $max = 0): void
{
$this->progressBar->start($max);
}
/**
* @inheritDoc
*/
public function advance(int $step = 1): void
{
$this->progressBar->advance($step);
}
/**
* @inheritDoc
*/
public function finish(): void
{
$this->progressBar->finish();
}
}

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges\Symfony;
use Phred\ConsoleContracts\Helpers\TableInterface;
use Symfony\Component\Console\Helper\Table;
/**
* Adapter for Symfony Table.
*/
class SymfonyTableAdapter implements TableInterface
{
public function __construct(private readonly Table $table)
{
}
/**
* @inheritDoc
*/
public function setHeaders(array $headers): void
{
$this->table->setHeaders($headers);
}
/**
* @inheritDoc
*/
public function addRow(array $row): void
{
$this->table->addRow($row);
}
/**
* @inheritDoc
*/
public function setColumnAlignments(array $alignments): void
{
// Symfony doesn't have a direct Index => Alignment method on the Table helper
// that works exactly like this, but we can set them individually
foreach ($alignments as $index => $alignment) {
$this->table->setColumnStyle($index, $alignment);
}
}
/**
* @inheritDoc
*/
public function render(): void
{
$this->table->render();
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges\Tests;
use PHPUnit\Framework\TestCase;
use Phred\ConsoleContracts\CommandInterface;
use Phred\ConsoleContracts\InputInterface;
use Phred\ConsoleContracts\OutputInterface;
use Phred\TaskerBridges\Laravel\LaravelCommandAdapter;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
class LaravelBridgeTest extends TestCase
{
public function testLaravelCommandAdapter(): void
{
$phredCommand = new class implements CommandInterface {
public function getName(): string { return 'test:phred'; }
public function getDescription(): string { return 'Test description'; }
public function getArguments(): array { return ['name' => 'Name']; }
public function getOptions(): array { return []; }
public function execute(InputInterface $input, OutputInterface $output): int
{
$output->info('Hello ' . $input->getArgument('name'));
return 0;
}
};
$adapter = new LaravelCommandAdapter($phredCommand);
// Mock Laravel components
$symfonyInput = new ArrayInput(['name' => 'John'], $adapter->getDefinition());
$symfonyOutput = new BufferedOutput();
// Use reflection to set protected properties as Laravel's constructor and run logic is complex
$refl = new \ReflectionClass($adapter);
$inputProp = $refl->getProperty('input');
$inputProp->setValue($adapter, $symfonyInput);
$outputProp = $refl->getProperty('output');
$outputProp->setValue($adapter, $symfonyOutput);
$exitCode = $adapter->handle();
$this->assertEquals(0, $exitCode);
$this->assertStringContainsString('Hello John', $symfonyOutput->fetch());
}
}

136
tests/PhredBridgeTest.php Normal file
View file

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges\Tests;
use PHPUnit\Framework\TestCase;
use Phred\TaskerBridges\Phred\InputAdapter;
use Phred\TaskerBridges\Phred\OutputAdapter;
use Phred\TaskerBridges\Phred\TableAdapter;
use Phred\TaskerBridges\Phred\MarkdownConverter;
use Phred\TaskerBridges\Phred\InteractionAdapter;
use Phred\TaskerBridges\Phred\ProgressBarAdapter;
class PhredBridgeTest extends TestCase
{
public function testInputAdapter(): void
{
$adapter = new InputAdapter(
['arg1' => 'val1'],
['opt1' => 'val2']
);
$this->assertEquals('val1', $adapter->getArgument('arg1'));
$this->assertEquals('default', $adapter->getArgument('arg2', 'default'));
$this->assertEquals('val2', $adapter->getOption('opt1'));
$this->assertTrue($adapter->hasOption('opt1'));
$this->assertFalse($adapter->hasOption('opt2'));
}
public function testOutputAdapterFormatting(): void
{
$adapter = new OutputAdapter(true);
ob_start();
$adapter->info('test');
$output = ob_get_clean();
$this->assertStringContainsString("\033[34mtest\033[0m", $output);
$adapter = new OutputAdapter(false);
ob_start();
$adapter->info('test');
$output = ob_get_clean();
$this->assertEquals("test" . PHP_EOL, $output);
}
public function testTableAdapter(): void
{
$output = new OutputAdapter(false);
$table = new TableAdapter($output);
$table->setHeaders(['ID', 'Name', 'Status']);
$table->setColumnAlignments([0 => 'right', 1 => 'left', 2 => 'center']);
$table->addRow([1, 'John', 'Active']);
$table->addRow([100, 'Jane', 'Inactive']);
ob_start();
$table->render();
$content = ob_get_clean();
// ID column (right aligned)
$this->assertStringContainsString('| 1 |', $content);
$this->assertStringContainsString('| 100 |', $content);
// Name column (left aligned)
$this->assertStringContainsString('| John |', $content);
// Status column (center aligned)
$this->assertStringContainsString('| Active |', $content);
}
public function testMarkdownConverter(): void
{
$output = new OutputAdapter(false);
$converter = new MarkdownConverter($output);
$this->assertEquals('<info><b>Title</b></info>', $converter->convert('# Title'));
$this->assertEquals('<b>Bold</b>', $converter->convert('**Bold**'));
$this->assertEquals('<i>Italic</i>', $converter->convert('*Italic*'));
$this->assertEquals('<comment>code</comment>', $converter->convert('`code`'));
}
public function testMarkdownTableConverter(): void
{
$output = new OutputAdapter(false);
$converter = new MarkdownConverter($output);
$markdown = <<<MD
| ID | Name |
|---|---|
| 1 | John |
MD;
$result = $converter->convert($markdown);
$this->assertStringContainsString('| ID | Name |', $result);
$this->assertStringContainsString('| 1 | John |', $result);
}
public function testInteractionAdapter(): void
{
$input = fopen('php://memory', 'r+');
if ($input === false) {
$this->fail('Could not open memory stream');
}
fwrite($input, "John\ny\nsecret\n1\n");
rewind($input);
$adapter = new InteractionAdapter($input);
ob_start();
$this->assertEquals('John', $adapter->ask('Name'));
$this->assertTrue($adapter->confirm('Continue?'));
$this->assertEquals('secret', $adapter->secret('Password'));
$this->assertEquals('Choice 2', $adapter->choice('Select', ['0' => 'Choice 1', '1' => 'Choice 2']));
ob_end_clean();
fclose($input);
}
public function testProgressBarAdapter(): void
{
$output = new OutputAdapter(false);
$bar = new ProgressBarAdapter($output);
ob_start();
$bar->start(100);
$bar->advance(50);
$bar->finish();
$content = ob_get_clean();
$this->assertStringContainsString('50%', $content);
$this->assertStringContainsString('100%', $content);
}
}

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Phred\TaskerBridges\Tests;
use PHPUnit\Framework\TestCase;
use Phred\ConsoleContracts\CommandInterface;
use Phred\ConsoleContracts\InputInterface;
use Phred\ConsoleContracts\OutputInterface;
use Phred\TaskerBridges\Symfony\SymfonyCommandAdapter;
use Phred\TaskerBridges\Symfony\SymfonyInputAdapter;
use Phred\TaskerBridges\Symfony\SymfonyOutputAdapter;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Output\OutputInterface as SymfonyOutputInterface;
class SymfonyBridgeTest extends TestCase
{
public function testSymfonyCommandAdapter(): void
{
$phredCommand = new class implements CommandInterface {
public function getName(): string { return 'test:phred'; }
public function getDescription(): string { return 'Test description'; }
public function getArguments(): array { return ['name' => 'Name']; }
public function getOptions(): array { return []; }
public function execute(InputInterface $input, OutputInterface $output): int
{
$output->info('Hello ' . $input->getArgument('name'));
return 0;
}
};
$adapter = new SymfonyCommandAdapter($phredCommand);
$app = new Application();
$app->add($adapter);
$app->setAutoExit(false);
$input = new ArrayInput(['test:phred', 'name' => 'John']);
$output = new BufferedOutput();
$exitCode = $app->run($input, $output);
$this->assertEquals(0, $exitCode);
$this->assertStringContainsString('Hello John', $output->fetch());
}
public function testVerbosityMapping(): void
{
$symfonyOutput = new BufferedOutput();
$adapter = new SymfonyOutputAdapter($symfonyOutput);
$adapter->setVerbosity(0);
$this->assertEquals(SymfonyOutputInterface::VERBOSITY_QUIET, $symfonyOutput->getVerbosity());
$adapter->setVerbosity(4);
$this->assertEquals(SymfonyOutputInterface::VERBOSITY_DEBUG, $symfonyOutput->getVerbosity());
$symfonyOutput->setVerbosity(SymfonyOutputInterface::VERBOSITY_VERBOSE);
$this->assertEquals(2, $adapter->getVerbosity());
}
public function testSymfonyHelpers(): void
{
$symfonyOutput = new BufferedOutput();
$table = new \Symfony\Component\Console\Helper\Table($symfonyOutput);
$adapter = new \Phred\TaskerBridges\Symfony\SymfonyTableAdapter($table);
$adapter->setHeaders(['ID', 'Name']);
$adapter->addRow([1, 'John']);
$adapter->render();
$output = $symfonyOutput->fetch();
$this->assertStringContainsString('ID', $output);
$this->assertStringContainsString('John', $output);
}
}