From 0710d8178318cdaf42294e86f4fc1fa37e020225 Mon Sep 17 00:00:00 2001 From: magdev Date: Sun, 3 May 2026 20:02:03 +0200 Subject: [PATCH] v0.2.0 (3/N): extract Maker shared helpers (NameInput, Naming) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 2 + .../php/src/Maker/BridgeCommandMaker.php | 21 ++++---- .../php/src/Maker/BridgeResourceMaker.php | 21 ++++---- framework/php/src/Maker/BridgeWindowMaker.php | 18 ++++--- framework/php/src/Maker/Support/NameInput.php | 48 +++++++++++++++++++ framework/php/src/Maker/Support/Naming.php | 27 +++++++++++ .../php/tests/Maker/Support/NamingTest.php | 31 ++++++++++++ 7 files changed, 136 insertions(+), 32 deletions(-) create mode 100644 framework/php/src/Maker/Support/NameInput.php create mode 100644 framework/php/src/Maker/Support/Naming.php create mode 100644 framework/php/tests/Maker/Support/NamingTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8106667..aaf6be6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ This section tracks work landing on `dev` toward **v0.2.0** (next minor; pre-1.0 - **`PublisherInterface`, `ModelPublisherInterface`, `CorrelationContextInterface`.** The bridge's three public services now ship as interfaces (same namespace as concrete, mirroring upstream `HubInterface`/`Hub`). App controllers and listeners should typehint these instead of the concrete classes so swappable implementations (offline-buffer publisher, request-stamp correlation context, etc.) remain non-breaking. Existing `Publisher` / `ModelPublisher` / `CorrelationContext` classes implement the new interfaces unchanged. - **`BridgeOp` enum.** PHP 8.1 string-backed enum (`Upsert` / `Delete` / `Replace` / `Event`) replacing the raw `'upsert'`/`'delete'` strings previously passed between `DoctrineBridgeListener` and `ModelPublisher::publishEntityChange`. Values match PLAN.md §4's envelope `op` wire format. Typo'd ops are now caught at the type level instead of silently producing envelopes clients ignore. - **`BridgeBundleInfo` value object** carrying the bundle's name + class FQCN. `HealthController` now constructor-injects this instead of `PublisherInterface` as the deep-load canary, so the readiness probe is no longer coupled to the publisher's contract. `/healthz` response gains a `name` field (`php-qml/bridge`); the `bundle` field now reports `PhpQml\Bridge\BridgeBundle` (was `PhpQml\Bridge\Publisher`). +- **`Maker\Support\NameInput`** — shared interactive name prompt. All three `make:bridge:*` makers (`resource`, `command`, `window`) re-implemented the same "prompt, trim, ucfirst, reject empty" closure inline; collapsed into one call site so empty-argument and validation behaviour stay in lockstep. +- **`Maker\Support\Naming`** — `camelTo($name, $separator)` helper. Replaces inline `preg_replace('/(?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('/(?createClassNameDetails( diff --git a/framework/php/src/Maker/BridgeResourceMaker.php b/framework/php/src/Maker/BridgeResourceMaker.php index 5bafc45..7124034 100644 --- a/framework/php/src/Maker/BridgeResourceMaker.php +++ b/framework/php/src/Maker/BridgeResourceMaker.php @@ -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('/(?createClassNameDetails( diff --git a/framework/php/src/Maker/BridgeWindowMaker.php b/framework/php/src/Maker/BridgeWindowMaker.php index 0c562e0..d33548c 100644 --- a/framework/php/src/Maker/BridgeWindowMaker.php +++ b/framework/php/src/Maker/BridgeWindowMaker.php @@ -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 diff --git a/framework/php/src/Maker/Support/NameInput.php b/framework/php/src/Maker/Support/NameInput.php new file mode 100644 index 0000000..fb36ea0 --- /dev/null +++ b/framework/php/src/Maker/Support/NameInput.php @@ -0,0 +1,48 @@ +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); + } +} diff --git a/framework/php/src/Maker/Support/Naming.php b/framework/php/src/Maker/Support/Naming.php new file mode 100644 index 0000000..6cfaf26 --- /dev/null +++ b/framework/php/src/Maker/Support/Naming.php @@ -0,0 +1,27 @@ + */ + public static function camelToCases(): iterable + { + yield 'single word, underscore' => ['Todo', '_', 'todo']; + yield 'two words, underscore' => ['TodoList', '_', 'todo_list']; + yield 'two words, dash' => ['MarkAllDone', '-', 'mark-all-done']; + yield 'leading uppercase, no split' => ['Todo', '-', 'todo']; + yield 'all caps stay together (acronym preserved)' => ['HTTPClient', '-', 'h-t-t-p-client']; + yield 'empty input' => ['', '-', '']; + } +}