|
| 1 | +<!-- |
| 2 | +SPDX-License-Identifier: ISC |
| 3 | +SPDX-FileCopyrightText: (c) Respect Project Contributors |
| 4 | +SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com> |
| 5 | +--> |
| 6 | + |
| 7 | +# Respect\FluentGen |
| 8 | + |
| 9 | +Generate PHP mixin interfaces from class namespaces, so IDEs can autocomplete |
| 10 | +`__call`-based fluent builder chains. |
| 11 | + |
| 12 | +When a builder resolves method calls dynamically, IDEs can't see the available |
| 13 | +methods. FluentGen solves this by scanning your classes, reflecting their |
| 14 | +constructors, and generating interface files that declare every method with |
| 15 | +proper signatures and return types. Your builder class then references the |
| 16 | +generated interface via a `@mixin` docblock, and autocompletion works. |
| 17 | + |
| 18 | +FluentGen works with any class namespace that follows a naming convention. If |
| 19 | +your classes use the `#[Composable]` attribute from |
| 20 | +[Respect/Fluent](https://github.com/Respect/Fluent), FluentGen additionally |
| 21 | +generates per-prefix composed interfaces, but that is not required. |
| 22 | + |
| 23 | +## Installation |
| 24 | + |
| 25 | +```bash |
| 26 | +composer require --dev respect/fluentgen |
| 27 | +``` |
| 28 | + |
| 29 | +Requires PHP 8.5+. |
| 30 | + |
| 31 | +## What it generates |
| 32 | + |
| 33 | +Given a namespace full of classes like `AreaFormatter`, `DateFormatter`, |
| 34 | +`MaskFormatter`, FluentGen produces two interfaces: |
| 35 | + |
| 36 | +- A **Builder** interface with static methods (`Builder::area()`, |
| 37 | + `Builder::date()`, etc.) for starting chains. |
| 38 | +- A **Chain** interface with instance methods (`->area()`, `->date()`, etc.) |
| 39 | + for continuing them. |
| 40 | + |
| 41 | +Each generated method mirrors the constructor signature of the underlying class. |
| 42 | +If `MaskFormatter` has `__construct(string $range, string $replacement = '*')`, |
| 43 | +the generated `mask()` method has the same parameters. Doc comments on the |
| 44 | +constructor are carried over too. |
| 45 | + |
| 46 | +## Setting it up |
| 47 | + |
| 48 | +FluentGen is typically wired into a Symfony Console command that you run during |
| 49 | +development. Here's how it looks like: |
| 50 | + |
| 51 | +First, configure what to scan and where to write: |
| 52 | + |
| 53 | +```php |
| 54 | +$config = new Config( |
| 55 | + sourceDir: __DIR__ . '/src', |
| 56 | + sourceNamespace: 'App\\Formatters', |
| 57 | + outputDir: __DIR__ . '/src/Mixins', |
| 58 | + outputNamespace: 'App\\Formatters\\Mixins', |
| 59 | +); |
| 60 | +``` |
| 61 | + |
| 62 | +`sourceDir` and `sourceNamespace` tell the scanner where your classes live. |
| 63 | +`outputDir` and `outputNamespace` control where the generated interfaces go. |
| 64 | + |
| 65 | +Next, set up scanning. The `NamespaceScanner` reflects every concrete class in |
| 66 | +the directory. You can filter by interface and exclude specific classes: |
| 67 | + |
| 68 | +```php |
| 69 | +$scanner = new NamespaceScanner( |
| 70 | + nodeType: Formatter::class, |
| 71 | + excludedClassNames: ['FormatterBuilder'], |
| 72 | +); |
| 73 | +``` |
| 74 | + |
| 75 | +Without filters, the scanner picks up every non-abstract class it finds. The |
| 76 | +`nodeType` filter restricts to classes implementing a given interface. The |
| 77 | +exclusion list removes specific classes by short name, useful for excluding the |
| 78 | +builder class itself if it lives in the same namespace. |
| 79 | + |
| 80 | +Then configure the generator. `MixinGenerator` needs to know what interfaces to |
| 81 | +produce. Each `InterfaceConfig` describes one: |
| 82 | + |
| 83 | +```php |
| 84 | +$generator = new MixinGenerator( |
| 85 | + config: $config, |
| 86 | + scanner: $scanner, |
| 87 | + methodBuilder: new MethodBuilder(classSuffix: 'Formatter'), |
| 88 | + interfaces: [ |
| 89 | + new InterfaceConfig( |
| 90 | + suffix: 'Builder', |
| 91 | + returnType: Chain::class, |
| 92 | + static: true, |
| 93 | + ), |
| 94 | + new InterfaceConfig( |
| 95 | + suffix: 'Chain', |
| 96 | + returnType: Chain::class, |
| 97 | + rootExtends: [Formatter::class], |
| 98 | + ), |
| 99 | + ], |
| 100 | +); |
| 101 | +``` |
| 102 | + |
| 103 | +The `suffix` determines the interface name. The `returnType` is what every |
| 104 | +generated method returns, typically your Chain interface, enabling fluent |
| 105 | +chaining. Set `static: true` for the builder entry point. Use `rootExtends` |
| 106 | +when the chain interface should extend your domain interface. |
| 107 | + |
| 108 | +The `MethodBuilder` handles how class names map to method names. The |
| 109 | +`classSuffix` option strips a suffix before generating: `AreaFormatter` becomes |
| 110 | +`area()`, `DateFormatter` becomes `date()`. |
| 111 | + |
| 112 | +Finally, call `generate()` to get a filename-to-content map: |
| 113 | + |
| 114 | +```php |
| 115 | +$files = $generator->generate(); |
| 116 | + |
| 117 | +foreach ($files as $filename => $content) { |
| 118 | + file_put_contents($filename, $content); |
| 119 | +} |
| 120 | +``` |
| 121 | + |
| 122 | +Run this as part of your dev tooling: a console command, a Composer script, or |
| 123 | +CI check that verifies generated files are up to date. |
| 124 | + |
| 125 | +## Composition support (optional, requires Respect/Fluent) |
| 126 | + |
| 127 | +Some libraries, like Respect/Validation, use prefix composition where |
| 128 | +`notEmail()` creates `Not(Email())`. If your classes use the `#[Composable]` |
| 129 | +attribute from Respect/Fluent, FluentGen handles this automatically. |
| 130 | + |
| 131 | +Install the optional dependency: |
| 132 | + |
| 133 | +```bash |
| 134 | +composer require respect/fluent |
| 135 | +``` |
| 136 | + |
| 137 | +The `MixinGenerator` discovers composable prefixes and generates per-prefix |
| 138 | +interfaces. For example, a `Not` class with `#[Composable('not')]` produces a |
| 139 | +`NotBuilder` interface containing `notEmail()`, `notString()`, etc., and a root |
| 140 | +`Builder` interface that extends all prefix interfaces. |
| 141 | + |
| 142 | +Composition constraints (`without`, `with`, `optIn` on the `Composable` |
| 143 | +attribute) are respected during generation. Forbidden combinations are excluded |
| 144 | +from the generated interfaces. |
| 145 | + |
| 146 | +For the runtime prefix map, `PrefixConstantsGenerator` produces a constants |
| 147 | +class with `COMPOSABLE`, `COMPOSABLE_WITH_ARGUMENT`, and `FORBIDDEN` arrays |
| 148 | +that `ComposableMap` uses at resolve time. |
| 149 | + |
| 150 | +## Customization |
| 151 | + |
| 152 | +**MethodBuilder** controls how constructor parameters become method signatures. |
| 153 | +Beyond `classSuffix`, it supports `excludedTypePrefixes` and |
| 154 | +`excludedTypeNames` to skip parameters whose types come from external packages |
| 155 | +you don't want in your public interface. |
| 156 | + |
| 157 | +**FileRenderer** handles the final output, printing the generated namespace |
| 158 | +via Nette PHP Generator and applying the `OutputFormatter`. The formatter |
| 159 | +preserves existing SPDX license headers, converts tabs to spaces, normalizes |
| 160 | +nullable syntax (`?Type` becomes `Type|null`), and collapses single-line doc |
| 161 | +comments. Both are used with sensible defaults; you rarely need to customize |
| 162 | +them. |
| 163 | + |
| 164 | +**InterfaceConfig** has a few more options for the root interface: |
| 165 | +`rootComment` adds a docblock (like `@mixin FormatterBuilder`), `rootUses` |
| 166 | +adds use statements, and `rootExtends` makes the interface extend others. |
0 commit comments