v0.2.0 (3/N): extract Maker shared helpers (NameInput, Naming)

DRY pass identified by the post-v0.1.2 audit: every make:bridge:*
maker re-implemented the same "prompt, trim, ucfirst, reject empty"
closure in interact(), and the camel-case-to-separator regex was
duplicated between BridgeResourceMaker (`_`-joined route plurals)
and BridgeCommandMaker (`-`-joined kebab slugs).

Two helpers under PhpQml\Bridge\Maker\Support:
  - NameInput::askOrFail() — replaces 3× inline closures
  - Naming::camelTo($name, $separator) — replaces 2× inline regexes

All 3 makers now go through the helpers; behaviour preserved
(maker snapshot test still passes — generated Todo / TodoController
/ TodoList / MarkAllDoneController / TodoWindow byte-identical to
the v0.1.2 baselines).

NamingTest covers the documented cases plus a regression case for
acronyms (HTTPClient → h-t-t-p-client; the regex splits at every
internal capital, which is correct for the route-slug use case).

Test count 17 → 23, all passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 20:02:03 +02:00
parent 0cca0785c0
commit 0710d81783
7 changed files with 136 additions and 32 deletions

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace PhpQml\Bridge\Maker;
use Doctrine\ORM\EntityManagerInterface;
use PhpQml\Bridge\Maker\Support\NameInput;
use PhpQml\Bridge\Maker\Support\Naming;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
@@ -61,23 +63,20 @@ final class BridgeCommandMaker extends AbstractMaker
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
if (null === $input->getArgument('name')) {
$name = $io->ask('Command name (CamelCase)?', null, static function (?string $v): string {
if (null === $v || '' === trim($v)) {
throw new \RuntimeException('Command name cannot be empty.');
}
return ucfirst(trim($v));
});
$input->setArgument('name', $name);
}
NameInput::askOrFail(
$input,
$io,
'name',
'Command name (CamelCase)?',
'Command name cannot be empty.',
);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$rawName = (string) $input->getArgument('name');
$singular = ucfirst(Str::asCamelCase($rawName));
$kebab = strtolower(preg_replace('/(?<!^)[A-Z]/', '-$0', $singular) ?? $singular);
$kebab = Naming::camelTo($singular, '-');
$route = '/api/'.$kebab;
$controllerFqcn = $generator->createClassNameDetails(

View File

@@ -7,6 +7,8 @@ namespace PhpQml\Bridge\Maker;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping as ORM;
use PhpQml\Bridge\Attribute\BridgeResource;
use PhpQml\Bridge\Maker\Support\NameInput;
use PhpQml\Bridge\Maker\Support\Naming;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
@@ -80,16 +82,13 @@ final class BridgeResourceMaker extends AbstractMaker
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
if (null === $input->getArgument('name')) {
$name = $io->ask('What is the resource name (e.g. Todo)?', null, static function (?string $v): string {
if (null === $v || '' === trim($v)) {
throw new \RuntimeException('Resource name cannot be empty.');
}
return ucfirst(trim($v));
});
$input->setArgument('name', $name);
}
NameInput::askOrFail(
$input,
$io,
'name',
'What is the resource name (e.g. Todo)?',
'Resource name cannot be empty.',
);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
@@ -100,7 +99,7 @@ final class BridgeResourceMaker extends AbstractMaker
$singular = ucfirst(Str::asCamelCase($rawName));
$pluralCamel = Str::singularCamelCaseToPluralCamelCase($singular);
$resource = strtolower($singular);
$pluralUnder = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $pluralCamel) ?? $pluralCamel);
$pluralUnder = Naming::camelTo($pluralCamel, '_');
$route = '/api/'.$pluralUnder;
$entityFqcn = $generator->createClassNameDetails(

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace PhpQml\Bridge\Maker;
use PhpQml\Bridge\Maker\Support\NameInput;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
@@ -57,16 +58,13 @@ final class BridgeWindowMaker extends AbstractMaker
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
if (null === $input->getArgument('name')) {
$name = $io->ask('Window name?', null, static function (?string $v): string {
if (null === $v || '' === trim($v)) {
throw new \RuntimeException('Window name cannot be empty.');
}
return ucfirst(trim($v));
});
$input->setArgument('name', $name);
}
NameInput::askOrFail(
$input,
$io,
'name',
'Window name?',
'Window name cannot be empty.',
);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge\Maker\Support;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Component\Console\Input\InputInterface;
/**
* Shared interactive name prompt for the bridge makers.
*
* Every `make:bridge:*` maker takes a single CamelCase `name` argument
* and re-implemented the same "prompt, trim, ucfirst, reject empty"
* closure inline. This collapses that into one call site so the empty-
* argument and validation behaviour stay in lockstep across makers.
*/
final class NameInput
{
/**
* Fill the named argument from an interactive prompt if it isn't set.
*
* The closure-based validator throws `\RuntimeException` on empty input,
* which Maker-bundle's `ConsoleStyle::ask()` interprets as "render
* error, re-prompt" rather than aborting — same behaviour as the
* inline closures it replaces.
*/
public static function askOrFail(
InputInterface $input,
ConsoleStyle $io,
string $argument,
string $question,
string $errorMessage = 'Name cannot be empty.',
): void {
if (null !== $input->getArgument($argument)) {
return;
}
$value = $io->ask($question, null, static function (?string $v) use ($errorMessage): string {
if (null === $v || '' === trim($v)) {
throw new \RuntimeException($errorMessage);
}
return ucfirst(trim($v));
});
$input->setArgument($argument, $value);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge\Maker\Support;
/**
* CamelCase → separator-joined lowercase converter shared between makers.
*
* `BridgeResourceMaker` needs `_` (route plural → table-style); `BridgeCommandMaker`
* needs `-` (kebab route slug). Same regex, two separators — collapsed here so
* the regex lives in one place.
*/
final class Naming
{
/**
* Convert a CamelCase identifier to a separator-joined lowercase string.
*
* camelTo('TodoList', '_') === 'todo_list'
* camelTo('MarkAllDone', '-') === 'mark-all-done'
* camelTo('Todo', '-') === 'todo'
*/
public static function camelTo(string $name, string $separator): string
{
return strtolower(preg_replace('/(?<!^)[A-Z]/', $separator.'$0', $name) ?? $name);
}
}