From 2dd4ab964e01670384c98405132279415b358ba9 Mon Sep 17 00:00:00 2001 From: kafkiansky Date: Sun, 9 Aug 2026 11:00:06 +0300 Subject: [PATCH 1/5] Relocate well known packages into alternative php namespaces to resolve conflicts with google/protobuf --- README.md | 51 ++++ bin/compiler.php | 12 +- box.json | 3 + docker/php/Dockerfile | 3 +- map.json | 11 + src/Exception/InvalidNamespaceMapping.php | 12 + src/Plugin/ClassLikeGenerator.php | 3 +- src/Plugin/Compiler.php | 37 +-- src/Plugin/Dependency/Registry.php | 18 +- src/Plugin/Generator/FileFactory.php | 29 ++- src/Plugin/Generator/PhpNamespacer.php | 46 +++- src/Plugin/Generator/ProtoGenerator.php | 34 ++- src/Plugin/Mapping/ExactRule.php | 46 ++++ src/Plugin/Mapping/PatternRule.php | 68 ++++++ src/Plugin/Mapping/Rule.php | 44 ++++ src/Plugin/Mapping/TypeMap.php | 220 ++++++++++++++++++ src/Plugin/NamespaceResolver.php | 76 ++++++ tests/CompilerTest.php | 5 +- tests/Plugin/Mapping/TypeMapTest.php | 128 ++++++++++ tests/fixtures/namespace_mapping/status.proto | 32 +++ .../Thesis/Queue/V1/QueueServiceClient.php | 6 +- .../Thesis/Queue/V1/QueueServiceServer.php | 4 +- .../Thesis/Google/Rpc/DescriptorRegistry.php | 51 ++++ .../Thesis/Google/Rpc/Status.php | 37 +++ .../Thesis/Google/Rpc/Status/Detail.php | 26 +++ .../Thesis/Google/Rpc/Status/Kind.php | 22 ++ .../Thesis/Google/Rpc/Status/Payload.php | 22 ++ .../Google/Rpc/Status/PayloadStructured.php | 26 +++ .../Thesis/Google/Rpc/Status/PayloadText.php | 26 +++ .../Thesis/Google/Rpc/StatusServiceClient.php | 55 +++++ .../Thesis/Google/Rpc/StatusServiceServer.php | 28 +++ .../Rpc/StatusServiceServerRegistry.php | 39 ++++ .../Thesis/Google/Rpc/autoload.metadata.php | 14 ++ .../proto2/Proto/Api/V1/TestRequest.php | 20 +- 34 files changed, 1175 insertions(+), 79 deletions(-) create mode 100644 map.json create mode 100644 src/Exception/InvalidNamespaceMapping.php create mode 100644 src/Plugin/Mapping/ExactRule.php create mode 100644 src/Plugin/Mapping/PatternRule.php create mode 100644 src/Plugin/Mapping/Rule.php create mode 100644 src/Plugin/Mapping/TypeMap.php create mode 100644 src/Plugin/NamespaceResolver.php create mode 100644 tests/Plugin/Mapping/TypeMapTest.php create mode 100644 tests/fixtures/namespace_mapping/status.proto create mode 100644 tests/snapshots/namespace_mapping/Thesis/Google/Rpc/DescriptorRegistry.php create mode 100644 tests/snapshots/namespace_mapping/Thesis/Google/Rpc/Status.php create mode 100644 tests/snapshots/namespace_mapping/Thesis/Google/Rpc/Status/Detail.php create mode 100644 tests/snapshots/namespace_mapping/Thesis/Google/Rpc/Status/Kind.php create mode 100644 tests/snapshots/namespace_mapping/Thesis/Google/Rpc/Status/Payload.php create mode 100644 tests/snapshots/namespace_mapping/Thesis/Google/Rpc/Status/PayloadStructured.php create mode 100644 tests/snapshots/namespace_mapping/Thesis/Google/Rpc/Status/PayloadText.php create mode 100644 tests/snapshots/namespace_mapping/Thesis/Google/Rpc/StatusServiceClient.php create mode 100644 tests/snapshots/namespace_mapping/Thesis/Google/Rpc/StatusServiceServer.php create mode 100644 tests/snapshots/namespace_mapping/Thesis/Google/Rpc/StatusServiceServerRegistry.php create mode 100644 tests/snapshots/namespace_mapping/Thesis/Google/Rpc/autoload.metadata.php diff --git a/README.md b/README.md index 2414fa9..4a24b4e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ For this reason, we have written this plugin, which — in addition to addressin - [memory limit](#memory-limit) - [metadata limit](#metadata) - [multiple options](#multiple-options) +- [Namespace mapping](#namespace-mapping) - [Generated code guide](#generated-code-guide) - [numbers](#numbers) - [repeated](#repeated) @@ -242,6 +243,56 @@ protoc \ --php-plugin_out=genproto ``` +### Namespace mapping + +Thesis is an alternative runtime, therefore its generated code must be able to live in the same project as the code +generated by the standard `google/protobuf` runtime. That is impossible as long as both generate, say, `Google\Protobuf\Any`: +the class names collide, which is why `thesis/protobuf-known-types` used to declare `conflict: google/protobuf` — and that +conflict, in turn, made it impossible to use packages tied to `google/protobuf` (`open-telemetry/*`, for instance). + +To solve this, the plugin relocates well-known packages into namespaces of its own. The relocation table is the +[`map.json`](map.json) file shipped with the plugin (it is bundled into the phar and into the docker image, so it applies no +matter how the plugin is installed): + +```json +{ + "rules": [ + {"from": "google.protobuf.*", "to": "thesis.google.protobuf.*"}, + {"from": "google.rpc.Status", "to": "thesis.google.rpc.Status"} + ] +} +``` + +A rule is written in terms of protobuf names, not PHP ones, the PHP namespace is derived from the rewritten name exactly +the same way it is derived from a package: + +| `from` | `to` | effect | +|---------------------|----------------------------|---------------------------------------------------------------------------------------------------------------| +| `google.protobuf.*` | `thesis.google.protobuf.*` | pattern: the package itself and everything nested in it, `Google\Protobuf\Any` → `Thesis\Google\Protobuf\Any` | +| `google.rpc.Status` | `thesis.google.rpc.Status` | exact: one type only, `Google\Rpc\Status` → `Thesis\Google\Rpc\Status` | + +* both sides of a rule must be of the same kind: either both end with `.*`, or neither does; +* an exact rule wins over a pattern, and a longer pattern wins over a shorter one; +* names are rewritten in one pass — the result of a rule is never fed into another rule; +* a rule may only move a type, not rename it: the last segment of `from` and `to` must be equal; +* a rule wins over the `php_namespace` option, both the one from the schema and the one passed on the command line — + a type is relocated precisely because its default namespace is already taken. + +The rewriting never leaves PHP. Protobuf names themselves stay exactly as the schema declares them, so +`DescriptorRegistry` keeps registering `google.protobuf.Timestamp`: + +```php +$pool->add(Registry\Descriptor::base64(self::TIMESTAMP_DESCRIPTOR_BUFFER), new File( + name: 'google/protobuf/timestamp.proto', + messages: [ + new File\MessageDescriptor('google.protobuf.Timestamp', \Thesis\Google\Protobuf\Timestamp::class), + ], +)); +``` + +That is what keeps `Any` type urls and server reflection interoperable: other languages know nothing about +`thesis.google.protobuf.Timestamp`. + ### Generated code guide As mentioned above, the plugin generates simple DTOs without setters, getters, or inheritance. All metadata for protobuf serialization is stored in attribute `Thesis\Protobuf\Reflection\*`, and the generated DTOs only have a constructor with promoted properties. diff --git a/bin/compiler.php b/bin/compiler.php index febf52a..5e06121 100755 --- a/bin/compiler.php +++ b/bin/compiler.php @@ -25,8 +25,18 @@ $encoder = Encoder\Builder::buildDefault(); $decoder = Decoder\Builder::buildDefault(); +try { + // The relocation table shipped with the plugin: it tells under which php namespaces + // the well known packages are generated and referenced. + $types = Plugin\Mapping\TypeMap::fromFile(__DIR__ . '/../map.json'); +} catch (Protoc\ProtocException $e) { + fwrite(STDERR, "{$e->getMessage()}\n"); + + exit(1); +} + $entrypoint = new Protoc\Entrypoint( - new Plugin\Compiler($encoder), + new Plugin\Compiler($encoder, $types), $encoder, $decoder, ); diff --git a/box.json b/box.json index 94918ab..ac53170 100644 --- a/box.json +++ b/box.json @@ -5,6 +5,9 @@ "src", "vendor" ], + "files": [ + "map.json" + ], "main": "bin/compiler.php", "output": "protoc-gen-php", "shebang": "#!/usr/bin/env php" diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index db994a7..49da3b9 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -2,13 +2,12 @@ FROM alpine:3.20 AS protoc ARG PROTOC_VERSION=32.1 -RUN apk add --no-cache bash curl unzip && \ +RUN apk add --no-cache curl unzip && \ curl -LO "https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip" && \ unzip "protoc-${PROTOC_VERSION}-linux-x86_64.zip" -d /usr/local && \ rm "protoc-${PROTOC_VERSION}-linux-x86_64.zip" FROM ghcr.io/phpyh/php:8.4 -COPY --from=protoc /bin/bash /bin/bash COPY --from=protoc /usr/local/bin/protoc /usr/local/bin/protoc COPY --from=protoc /usr/local/include /usr/local/include diff --git a/map.json b/map.json new file mode 100644 index 0000000..35ece81 --- /dev/null +++ b/map.json @@ -0,0 +1,11 @@ +{ + "rules": [ + {"from": "google.protobuf.*", "to": "thesis.google.protobuf.*"}, + {"from": "google.rpc.*", "to": "thesis.google.rpc.*"}, + {"from": "google.api.*", "to": "thesis.google.api.*"}, + {"from": "google.type.*", "to": "thesis.google.type.*"}, + {"from": "grpc.health.v1.*", "to": "thesis.grpc.health.v1.*"}, + {"from": "grpc.reflection.v1.*", "to": "thesis.grpc.reflection.v1.*"}, + {"from": "grpc.reflection.v1alpha.*", "to": "thesis.grpc.reflection.v1alpha.*"} + ] +} diff --git a/src/Exception/InvalidNamespaceMapping.php b/src/Exception/InvalidNamespaceMapping.php new file mode 100644 index 0000000..cbde290 --- /dev/null +++ b/src/Exception/InvalidNamespaceMapping.php @@ -0,0 +1,12 @@ +grpc = new Generator\GrpcGenerator( $namespacer, $graph, diff --git a/src/Plugin/Compiler.php b/src/Plugin/Compiler.php index c973850..7d8f0e0 100644 --- a/src/Plugin/Compiler.php +++ b/src/Plugin/Compiler.php @@ -15,7 +15,6 @@ use Thesis\Protoc\Plugin\Generator\DescriptorMetadataRegistryGenerator; use Thesis\Protoc\Plugin\Generator\FileFactory; use Thesis\Protoc\Plugin\Generator\PhpNamespacer; -use Thesis\Protoc\Plugin\Parser\FileDescriptor; use Thesis\Protoc\Plugin\Parser\MessageDescriptor; use Thesis\Protoc\Plugin\Parser\ServiceMethodDescriptor; use Thesis\Protoc\ProtocException; @@ -31,6 +30,7 @@ public function __construct( private Encoder $encoder, + private Mapping\TypeMap $types, ) { $this->parser = new Parser(); } @@ -60,14 +60,16 @@ private function doGenerate( ): iterable { $request = $this->parser->parse($request); - $registry = new Dependency\Registry($request, $options); + $namespaces = new NamespaceResolver($this->types, $options); + + $registry = new Dependency\Registry($request, $namespaces); $descriptorPaths = new PathTable(); $registries = new DescriptorTable(); foreach ($request as $source => $proto) { - $phpNamespace = self::determinePhpNamespace($proto, $options); + $phpNamespace = $namespaces->file($proto) ?? throw self::namespaceCannotBeDetermined(); $index = new NameIndex(); @@ -78,12 +80,14 @@ private function doGenerate( files: new FileFactory( self::createClassLikeGeneratedDoc($request, $source), $path, + $phpNamespace, ), graph: $registry->graph($source), index: $index, package: $proto->package, syntax: $proto->syntax, edition: $proto->file->edition, + types: $this->types, ); foreach ($proto->services as $service) { @@ -185,30 +189,9 @@ private function doGenerateMessages(ClassLikeGenerator $generator, MessageDescri } } - /** - * @return non-empty-string - * @throws CodeCannotBeGenerated - */ - private static function determinePhpNamespace( - FileDescriptor $descriptor, - CompilerOptions $options, - ): string { - if ($options->phpNamespace !== null) { - return $options->phpNamespace; - } - - $phpNamespace = $descriptor->options?->phpNamespace; - if ($phpNamespace !== null && $phpNamespace !== '') { - return $phpNamespace; - } - - $package = $descriptor->package; - if ($package !== null && $package !== '') { - /** @var non-empty-string */ - return Naming::joinNamespace(explode('.', $package)); - } - - throw new CodeCannotBeGenerated('neither "package" nor "php_namespace" option was specified in the provided proto files, therefore I cannot determine the namespace under which the PHP files should be created. + private static function namespaceCannotBeDetermined(): CodeCannotBeGenerated + { + return new CodeCannotBeGenerated('neither "package" nor "php_namespace" option was specified in the provided proto files, therefore I cannot determine the namespace under which the PHP files should be created. If you cannot modify the proto files, please pass the namespace via command-line arguments as follows: --custom-plugin_out=php_namespace=App\\\Service\\\V1:path/to/generated'); } diff --git a/src/Plugin/Dependency/Registry.php b/src/Plugin/Dependency/Registry.php index 6a456ff..0fef475 100644 --- a/src/Plugin/Dependency/Registry.php +++ b/src/Plugin/Dependency/Registry.php @@ -4,7 +4,7 @@ namespace Thesis\Protoc\Plugin\Dependency; -use Thesis\Protoc\Plugin\CompilerOptions; +use Thesis\Protoc\Plugin\NamespaceResolver; use Thesis\Protoc\Plugin\Naming; use Thesis\Protoc\Plugin\Parser; @@ -22,9 +22,11 @@ final class Registry /** @var array> */ private array $fileIndexes = []; - public function __construct(Parser\Request $request, CompilerOptions $options) - { - $this->createIndex($request, $options); + public function __construct( + Parser\Request $request, + private readonly NamespaceResolver $namespaces, + ) { + $this->createIndex($request, $namespaces); $this->mergeIndex($request); $this->createDependencyGraph($request); } @@ -34,7 +36,7 @@ public function graph(string $name): Graph return $this->graph[$name] ?? throw new \RuntimeException("Graph for file {$name} does not exist."); } - private function createIndex(Parser\Request $request, CompilerOptions $options): void + private function createIndex(Parser\Request $request, NamespaceResolver $namespaces): void { foreach ($request->descriptors as $name => $descriptor) { $types = iterator_to_array( @@ -45,7 +47,7 @@ private function createIndex(Parser\Request $request, CompilerOptions $options): ); $package = $descriptor->package ?? '.'; - $namespace = $options->phpNamespace ?? $descriptor->options->phpNamespace ?? Naming::namespace($package); + $namespace = $namespaces->file($descriptor) ?? Naming::namespace($package); $this->index[$name] = new Index( $types, @@ -192,6 +194,10 @@ private function extractTypes(array $typeNames, string $proto): iterable $class = Naming::namespace(substr($typeName, \strlen($prefix))); $fqcn = "\\{$index->namespace}\\{$class}"; + // A type relocated by an exact rule leaves the namespace of its own + // package behind, so its name is resolved from the rule alone. + $fqcn = $this->namespaces->type($typeName) ?? $fqcn; + yield $typeName => new Type( $fqcn, $class, diff --git a/src/Plugin/Generator/FileFactory.php b/src/Plugin/Generator/FileFactory.php index 37daa0c..57a245b 100644 --- a/src/Plugin/Generator/FileFactory.php +++ b/src/Plugin/Generator/FileFactory.php @@ -18,9 +18,14 @@ { private PsrPrinter $printer; + /** + * @param string $path directory the $namespace maps onto + * @param ?string $namespace namespace the file the code belongs to is generated into + */ public function __construct( private string $generatedDoc, private string $path, + private ?string $namespace = null, ) { $this->printer = new Printer()->setTypeResolving(false); } @@ -52,8 +57,30 @@ public function create( }; return new CodeGeneratorResponse\File( - name: \sprintf('%s/%s.php', $this->path, $code instanceof PhpNamespace ? Naming::path($path) : $path), + name: $code instanceof PhpNamespace + ? $this->resolvePath($code->getName(), $path) + : \sprintf('%s/%s.php', $this->path, $path), content: $content, ); } + + /** + * Types normally live under the namespace of their file, so the path they are written + * to is the one configured for that file. A type moved elsewhere by a relocation rule + * no longer belongs there and is written under its own namespace instead. + */ + private function resolvePath(string $namespace, string $path): string + { + if ($this->namespace === null || $namespace === $this->namespace || str_starts_with($namespace, "{$this->namespace}\\")) { + return \sprintf('%s/%s.php', $this->path, Naming::path($path)); + } + + $paths = explode('.', $path); + + return \sprintf( + '%s/%s.php', + str_replace('\\', '/', $namespace), + Naming::pascalCase($paths[\count($paths) - 1]), + ); + } } diff --git a/src/Plugin/Generator/PhpNamespacer.php b/src/Plugin/Generator/PhpNamespacer.php index 7e302aa..5a6812c 100644 --- a/src/Plugin/Generator/PhpNamespacer.php +++ b/src/Plugin/Generator/PhpNamespacer.php @@ -5,6 +5,7 @@ namespace Thesis\Protoc\Plugin\Generator; use Nette\PhpGenerator\PhpNamespace; +use Thesis\Protoc\Plugin\Mapping\TypeMap; use Thesis\Protoc\Plugin\Naming; /** @@ -14,21 +15,58 @@ { public function __construct( public string $namespace, + private TypeMap $types = new TypeMap(), + private ?string $package = null, ) {} public function create(string $path): PhpNamespace { - $namespace = $this->namespace; + return new PhpNamespace($this->resolve($path)); + } + + public function fqcn(string $path): string + { + $paths = explode('.', $path); + $name = $paths[\count($paths) - 1]; + + return Naming::joinNamespace([ + '', + $this->resolve($path), + $name, + ]); + } + + private function resolve(string $path): string + { + $rewritten = $this->rewrite($path); + + if ($rewritten !== null) { + $segments = explode('.', $rewritten); + array_pop($segments); + + return Naming::joinNamespace($segments); + } $paths = explode('.', $path); $typeNamespace = \array_slice($paths, 0, \count($paths) - 1); + if (\count($typeNamespace) > 0) { - $namespace = Naming::joinNamespace([ - $namespace, + return Naming::joinNamespace([ + $this->namespace, ...$typeNamespace, ]); } - return new PhpNamespace($namespace); + return $this->namespace; + } + + /** + * @return ?non-empty-string + */ + private function rewrite(string $path): ?string + { + $type = $this->package !== null && $this->package !== '' ? "{$this->package}.{$path}" : $path; + + return $this->types->rewriteType($type); } } diff --git a/src/Plugin/Generator/ProtoGenerator.php b/src/Plugin/Generator/ProtoGenerator.php index c20761c..2972da2 100644 --- a/src/Plugin/Generator/ProtoGenerator.php +++ b/src/Plugin/Generator/ProtoGenerator.php @@ -29,13 +29,13 @@ private TypeDeclarationFactory $types; public function __construct( - Dependency\Graph $graph, + private Dependency\Graph $graph, private NameIndex $index, private PhpNamespacer $namespacer, private ?string $syntax = null, private ?Edition $edition = null, ) { - $this->types = new TypeDeclarationFactory($graph); + $this->types = new TypeDeclarationFactory($this->graph); } public function generateEnum(Parser\EnumDescriptor $enum): PhpNamespace @@ -298,23 +298,18 @@ private function generateMessage(PhpNamespace $namespace, string $className, Par $constructor ->addPromotedParameter(Naming::propertyName($oneOf->name)) - ->setType(Naming::joinNamespace([ - '', - $this->namespacer->namespace, - $message->path, - $oneOfName, - ])) + ->setType($this->namespacer->fqcn("{$message->path}.{$oneOfName}")) ->setNullable() ->setDefaultValue(null) ->addAttribute('Reflection\OneOf', [ array_map( fn(Parser\FieldDescriptor $variant) => new Literal( - Naming::joinNamespace([ - '', - $this->namespacer->namespace, - $message->path, - \sprintf('%s::class', self::oneofVariantName($oneOfName, $variant)), - ]), + \sprintf( + '%s::class', + $this->namespacer->fqcn( + "{$message->path}." . self::oneofVariantName($oneOfName, $variant), + ), + ), ), $variants, ), @@ -413,12 +408,7 @@ private function generateOneofVariant( Naming::pascalCase($className), $descriptor, [ - Naming::joinNamespace([ - '', - $this->namespacer->namespace, - $message->path, - $interfaceName, - ]), + $this->namespacer->fqcn("{$message->path}.{$interfaceName}"), ], ); } @@ -429,7 +419,9 @@ private function parseDefaultValue( ?string $typename = null, ): mixed { if ($type === FieldDescriptorProto\Type::TYPE_ENUM && $typename !== null) { - return new Literal(\sprintf("%s::{$defaultValue}", Naming::namespace($typename))); + // Resolved through the graph, so that a relocated enum is referenced + // under the very namespace it is generated into. + return new Literal(\sprintf("%s::{$defaultValue}", $this->graph->get($typename)->fqcn)); } return match ($type) { diff --git a/src/Plugin/Mapping/ExactRule.php b/src/Plugin/Mapping/ExactRule.php new file mode 100644 index 0000000..fade452 --- /dev/null +++ b/src/Plugin/Mapping/ExactRule.php @@ -0,0 +1,46 @@ +from; + } + + #[\Override] + public function rewriteType(string $type): ?string + { + return $type === $this->from ? $this->to : null; + } + + #[\Override] + public function rewritePackage(string $package): ?string + { + return null; + } + + #[\Override] + public function priority(): int + { + return 2 * \strlen($this->from) + 1; + } +} diff --git a/src/Plugin/Mapping/PatternRule.php b/src/Plugin/Mapping/PatternRule.php new file mode 100644 index 0000000..d5ff563 --- /dev/null +++ b/src/Plugin/Mapping/PatternRule.php @@ -0,0 +1,68 @@ +from . self::SUFFIX; + } + + #[\Override] + public function rewriteType(string $type): ?string + { + return $this->rewrite($type); + } + + #[\Override] + public function rewritePackage(string $package): ?string + { + return $this->rewrite($package); + } + + #[\Override] + public function priority(): int + { + return 2 * \strlen($this->from); + } + + /** + * @return ?non-empty-string + */ + private function rewrite(string $name): ?string + { + if ($name === $this->from) { + return $this->to; + } + + if (str_starts_with($name, "{$this->from}.")) { + return $this->to . substr($name, \strlen($this->from)); + } + + return null; + } +} diff --git a/src/Plugin/Mapping/Rule.php b/src/Plugin/Mapping/Rule.php new file mode 100644 index 0000000..2b2022c --- /dev/null +++ b/src/Plugin/Mapping/Rule.php @@ -0,0 +1,44 @@ + */ + public array $rules; + + /** + * @param list $rules + */ + public function __construct(array $rules = []) + { + usort($rules, static fn(Rule $a, Rule $b): int => $b->priority() <=> $a->priority()); + + $this->rules = $rules; + } + + /** + * @throws InvalidNamespaceMapping + */ + public static function fromFile(string $file): self + { + $json = @file_get_contents($file); + + if ($json === false) { + throw new InvalidNamespaceMapping("Namespace mapping file '{$file}' cannot be read."); + } + + return self::fromJson($json, $file); + } + + /** + * @throws InvalidNamespaceMapping + */ + public static function fromJson(string $json, string $source = 'namespace mapping'): self + { + try { + $mapping = json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new InvalidNamespaceMapping("Namespace mapping '{$source}' is not a valid json: {$e->getMessage()}.", previous: $e); + } + + if (!\is_array($mapping) || array_is_list($mapping)) { + throw new InvalidNamespaceMapping("Namespace mapping '{$source}' must be a json object."); + } + + $rules = $mapping['rules'] ?? []; + + if (!\is_array($rules) || !array_is_list($rules)) { + throw new InvalidNamespaceMapping("Namespace mapping '{$source}' must contain a list of rules under the 'rules' key."); + } + + $parsed = []; + $seen = []; + + /** @var mixed $rule */ + foreach ($rules as $index => $rule) { + $parsed[] = $parsedRule = self::parseRule($rule, $source, $index); + + $declaration = $parsedRule->declaration(); + + if (isset($seen[$declaration])) { + throw new InvalidNamespaceMapping("Namespace mapping '{$source}' declares '{$declaration}' more than once."); + } + + $seen[$declaration] = true; + } + + return new self($parsed); + } + + /** + * @param string $type fully qualified protobuf type name, with or without the leading dot + * @return ?non-empty-string rewritten name or null when no rule matches + */ + public function rewriteType(string $type): ?string + { + $name = ltrim($type, '.'); + + if ($name === '') { + return null; + } + + foreach ($this->rules as $rule) { + $rewritten = $rule->rewriteType($name); + + if ($rewritten !== null) { + return $rewritten; + } + } + + return null; + } + + /** + * @return ?non-empty-string rewritten package or null when no rule matches + */ + public function rewritePackage(string $package): ?string + { + foreach ($this->rules as $rule) { + $rewritten = $rule->rewritePackage($package); + + if ($rewritten !== null) { + return $rewritten; + } + } + + return null; + } + + /** + * @throws InvalidNamespaceMapping + */ + private static function parseRule(mixed $rule, string $source, int|string $index): Rule + { + $at = "rule #{$index} of namespace mapping '{$source}'"; + + if (!\is_array($rule)) { + throw new InvalidNamespaceMapping("The {$at} must be a json object with 'from' and 'to' keys."); + } + + if (($unknown = array_diff(array_keys($rule), ['from', 'to'])) !== []) { + throw new InvalidNamespaceMapping(\sprintf('The %s contains unknown keys: %s.', $at, implode(', ', array_map(strval(...), $unknown)))); + } + + $from = self::parseName($rule['from'] ?? null, 'from', $at); + $to = self::parseName($rule['to'] ?? null, 'to', $at); + + $fromIsPattern = str_ends_with($from, self::PATTERN_SUFFIX); + $toIsPattern = str_ends_with($to, self::PATTERN_SUFFIX); + + if ($fromIsPattern !== $toIsPattern) { + throw new InvalidNamespaceMapping("The {$at} mixes a pattern with an exact name: '{$from}' → '{$to}'. Either both or neither must end with '" . self::PATTERN_SUFFIX . "'."); + } + + if ($fromIsPattern && $toIsPattern) { + return new PatternRule( + self::validateName(substr($from, 0, -\strlen(self::PATTERN_SUFFIX)), 'from', $at), + self::validateName(substr($to, 0, -\strlen(self::PATTERN_SUFFIX)), 'to', $at), + ); + } + + $fromName = self::validateName($from, 'from', $at); + $toName = self::validateName($to, 'to', $at); + + // Only the namespace of a type may be rewritten. Renaming a type would mean + // that the generated class no longer matches the descriptor it is registered + // with, so we reject it instead of generating code nobody can use. + if (self::lastSegment($fromName) !== self::lastSegment($toName)) { + throw new InvalidNamespaceMapping("The {$at} renames a type: '{$fromName}' → '{$toName}'. Only the namespace a type lives in can be rewritten."); + } + + return new ExactRule($fromName, $toName); + } + + /** + * @return non-empty-string + * @throws InvalidNamespaceMapping + */ + private static function parseName(mixed $name, string $key, string $at): string + { + if (!\is_string($name) || $name === '') { + throw new InvalidNamespaceMapping("The {$at} must contain a non-empty string under the '{$key}' key."); + } + + return $name; + } + + /** + * @return non-empty-string + * @throws InvalidNamespaceMapping + */ + private static function validateName(string $name, string $key, string $at): string + { + $segments = explode('.', $name); + + foreach ($segments as $segment) { + if (preg_match(self::SEGMENT_PATTERN, $segment) !== 1) { + throw new InvalidNamespaceMapping("The '{$key}' name '{$name}' of the {$at} is not a valid protobuf name."); + } + } + + \assert($name !== ''); + + return $name; + } + + private static function lastSegment(string $name): string + { + $segments = explode('.', $name); + + return $segments[\count($segments) - 1]; + } +} diff --git a/src/Plugin/NamespaceResolver.php b/src/Plugin/NamespaceResolver.php new file mode 100644 index 0000000..82e9531 --- /dev/null +++ b/src/Plugin/NamespaceResolver.php @@ -0,0 +1,76 @@ +package; + + // A relocation rule wins over any php_namespace: types are moved exactly because + // their default namespace is already occupied by another runtime. + if ($package !== null && $package !== '') { + $rewritten = $this->types->rewritePackage($package); + + if ($rewritten !== null) { + return self::toPhpNamespace($rewritten); + } + } + + if ($this->options->phpNamespace !== null) { + return $this->options->phpNamespace; + } + + $phpNamespace = $descriptor->options?->phpNamespace; + if ($phpNamespace !== null && $phpNamespace !== '') { + return $phpNamespace; + } + + if ($package !== null && $package !== '') { + return self::toPhpNamespace($package); + } + + return null; + } + + /** + * @param string $type fully qualified protobuf type name, with or without the leading dot + * @return ?non-empty-string fully qualified php class name, or null when the type is not relocated + */ + public function type(string $type): ?string + { + $rewritten = $this->types->rewriteType($type); + + return $rewritten === null ? null : '\\' . self::toPhpNamespace($rewritten); + } + + /** + * @param non-empty-string $name + * @return non-empty-string + */ + private static function toPhpNamespace(string $name): string + { + /** @var non-empty-string */ + return Naming::joinNamespace(explode('.', $name)); + } +} diff --git a/tests/CompilerTest.php b/tests/CompilerTest.php index 74a7747..0c64538 100644 --- a/tests/CompilerTest.php +++ b/tests/CompilerTest.php @@ -12,6 +12,7 @@ use Thesis\Protobuf\Decoder; use Thesis\Protobuf\Encoder; use Thesis\Protoc\Plugin\Compiler; +use Thesis\Protoc\Plugin\Mapping\TypeMap; #[CoversClass(Compiler::class)] final class CompilerTest extends TestCase @@ -30,7 +31,9 @@ public function testCompile(string $file): void $request = $decoder->decode($bytes, CodeGeneratorRequest::class); - $actual = self::collectFiles(new Compiler($encoder)->compile($request)); + $types = TypeMap::fromFile(__DIR__ . '/../map.json'); + + $actual = self::collectFiles(new Compiler($encoder, $types)->compile($request)); $expected = self::collectSnapshots(__DIR__ . '/snapshots/' . \dirname($file)); self::assertSame( diff --git a/tests/Plugin/Mapping/TypeMapTest.php b/tests/Plugin/Mapping/TypeMapTest.php new file mode 100644 index 0000000..399a7db --- /dev/null +++ b/tests/Plugin/Mapping/TypeMapTest.php @@ -0,0 +1,128 @@ +rewriteType('google.protobuf.Any')); + self::assertNull($map->rewritePackage('google.protobuf')); + } + + public function testShippedMapIsValid(): void + { + $map = TypeMap::fromFile(__DIR__ . '/../../../map.json'); + + self::assertSame('thesis.google.protobuf.Any', $map->rewriteType('google.protobuf.Any')); + self::assertSame('thesis.google.protobuf', $map->rewritePackage('google.protobuf')); + } + + #[DataProvider('provideRewriteTypeCases')] + public function testRewriteType(string $type, ?string $expected): void + { + self::assertSame($expected, self::map()->rewriteType($type)); + } + + /** + * @return iterable + */ + public static function provideRewriteTypeCases(): iterable + { + yield 'pattern' => ['google.protobuf.Any', 'thesis.google.protobuf.Any']; + yield 'pattern with a leading dot' => ['.google.protobuf.Any', 'thesis.google.protobuf.Any']; + yield 'nested type' => ['google.protobuf.Struct.FieldsEntry', 'thesis.google.protobuf.Struct.FieldsEntry']; + yield 'nested package' => ['google.protobuf.compiler.Version', 'thesis.google.protobuf.compiler.Version']; + yield 'exact rule wins over a pattern' => ['google.rpc.Status', 'thesis.errors.Status']; + yield 'the longest pattern wins' => ['google.rpc.context.Rule', 'thesis.context.Rule']; + yield 'exact rule' => ['demo.Type', 'thesis.demo.Type']; + yield 'sibling of an exact rule' => ['demo.Another', null]; + yield 'unrelated package' => ['thesis.queue.v1.PushRequest', null]; + yield 'partially matching package' => ['googleapis.protobuf.Any', null]; + } + + #[DataProvider('provideRewritePackageCases')] + public function testRewritePackage(string $package, ?string $expected): void + { + self::assertSame($expected, self::map()->rewritePackage($package)); + } + + /** + * @return iterable + */ + public static function provideRewritePackageCases(): iterable + { + yield 'exactly the pattern' => ['google.protobuf', 'thesis.google.protobuf']; + yield 'nested package' => ['google.protobuf.compiler', 'thesis.google.protobuf.compiler']; + yield 'the longest pattern wins' => ['google.rpc.context', 'thesis.context']; + yield 'an exact rule says nothing about the package' => ['demo', null]; + yield 'unrelated package' => ['thesis.queue.v1', null]; + } + + #[DataProvider('provideInvalidMappingCases')] + public function testInvalidMapping(string $json, string $message): void + { + $this->expectException(InvalidNamespaceMapping::class); + $this->expectExceptionMessageMatches($message); + + TypeMap::fromJson($json, 'test.json'); + } + + /** + * @return iterable + */ + public static function provideInvalidMappingCases(): iterable + { + yield 'not a json' => ['{', '/is not a valid json/']; + yield 'not an object' => ['[]', '/must be a json object/']; + yield 'rules are not a list' => ['{"rules": {"a": "b"}}', '/must contain a list of rules/']; + yield 'rule is not an object' => ['{"rules": ["google.protobuf.*"]}', '/must be a json object/']; + yield 'unknown key' => ['{"rules": [{"from": "a.B", "to": "b.B", "when": "always"}]}', '/unknown keys: when/']; + yield 'missing to' => ['{"rules": [{"from": "a.B"}]}', "/'to' key/"]; + yield 'empty from' => ['{"rules": [{"from": "", "to": "b.B"}]}', "/'from' key/"]; + yield 'pattern mixed with an exact name' => ['{"rules": [{"from": "a.*", "to": "b.B"}]}', '/mixes a pattern with an exact name/']; + yield 'renaming a type' => ['{"rules": [{"from": "a.B", "to": "b.C"}]}', '/renames a type/']; + yield 'invalid name' => ['{"rules": [{"from": "a..B", "to": "b..B"}]}', '/is not a valid protobuf name/']; + yield 'invalid pattern' => ['{"rules": [{"from": "*", "to": "b.*"}]}', '/mixes a pattern with an exact name/']; + yield 'duplicated rule' => [ + '{"rules": [{"from": "a.*", "to": "b.*"}, {"from": "a.*", "to": "c.*"}]}', + "/declares 'a.\\*' more than once/", + ]; + } + + public function testDescriptionIsAllowed(): void + { + $map = TypeMap::fromJson('{"rules": [{"from": "a.*", "to": "b.*"}]}'); + + self::assertSame('b.C', $map->rewriteType('a.C')); + } + + private static function map(): TypeMap + { + return TypeMap::fromJson( + <<<'JSON' + { + "rules": [ + {"from": "google.protobuf.*", "to": "thesis.google.protobuf.*"}, + {"from": "google.rpc.*", "to": "thesis.rpc.*"}, + {"from": "google.rpc.context.*", "to": "thesis.context.*"}, + {"from": "google.rpc.Status", "to": "thesis.errors.Status"}, + {"from": "demo.Type", "to": "thesis.demo.Type"} + ] + } + JSON, + ); + } +} diff --git a/tests/fixtures/namespace_mapping/status.proto b/tests/fixtures/namespace_mapping/status.proto new file mode 100644 index 0000000..a6cbecb --- /dev/null +++ b/tests/fixtures/namespace_mapping/status.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +package google.rpc; + +import "google/protobuf/timestamp.proto"; + +// The package is relocated into Thesis\Google\Rpc by map.json, while the protobuf +// name of every type stays google.rpc.*. +message Status { + enum Kind { + KIND_UNSPECIFIED = 0; + KIND_RETRYABLE = 1; + } + + message Detail { + string reason = 1; + } + + int32 code = 1; + Kind kind = 2; + Detail detail = 3; + google.protobuf.Timestamp created_at = 4; + + oneof payload { + string text = 5; + Detail structured = 6; + } +} + +service StatusService { + rpc Get(Status) returns (Status); +} diff --git a/tests/snapshots/grpc/Thesis/Queue/V1/QueueServiceClient.php b/tests/snapshots/grpc/Thesis/Queue/V1/QueueServiceClient.php index b8bbdde..2b23155 100644 --- a/tests/snapshots/grpc/Thesis/Queue/V1/QueueServiceClient.php +++ b/tests/snapshots/grpc/Thesis/Queue/V1/QueueServiceClient.php @@ -28,16 +28,16 @@ public function __construct( ) {} /** - * @return Client\ClientStreamChannel<\Thesis\Queue\PushRequest\Message, \Google\Protobuf\Empty_> + * @return Client\ClientStreamChannel<\Thesis\Queue\PushRequest\Message, \Thesis\Google\Protobuf\Empty_> */ public function push( Metadata $md = new Metadata(), Cancellation $cancellation = new NullCancellation(), ): Client\ClientStreamChannel { - /** @var Client\Invoke<\Thesis\Queue\PushRequest\Message, \Google\Protobuf\Empty_> $invoke */ + /** @var Client\Invoke<\Thesis\Queue\PushRequest\Message, \Thesis\Google\Protobuf\Empty_> $invoke */ $invoke = new Client\Invoke( method: '/Thesis.Queue.V1.QueueService/Push', - output: \Google\Protobuf\Empty_::class, + output: \Thesis\Google\Protobuf\Empty_::class, type: Grpc\RpcType::ClientStream, ); diff --git a/tests/snapshots/grpc/Thesis/Queue/V1/QueueServiceServer.php b/tests/snapshots/grpc/Thesis/Queue/V1/QueueServiceServer.php index 7c63067..c942a4a 100644 --- a/tests/snapshots/grpc/Thesis/Queue/V1/QueueServiceServer.php +++ b/tests/snapshots/grpc/Thesis/Queue/V1/QueueServiceServer.php @@ -22,13 +22,13 @@ interface QueueServiceServer { /** - * @param Server\ClientStreamChannel<\Thesis\Queue\PushRequest\Message, \Google\Protobuf\Empty_> $stream + * @param Server\ClientStreamChannel<\Thesis\Queue\PushRequest\Message, \Thesis\Google\Protobuf\Empty_> $stream */ public function push( Server\ClientStreamChannel $stream, Metadata $md, Cancellation $cancellation, - ): \Google\Protobuf\Empty_; + ): \Thesis\Google\Protobuf\Empty_; /** * @return iterable diff --git a/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/DescriptorRegistry.php b/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/DescriptorRegistry.php new file mode 100644 index 0000000..e5f82ea --- /dev/null +++ b/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/DescriptorRegistry.php @@ -0,0 +1,51 @@ +add(Registry\Descriptor::base64(self::STATUS_DESCRIPTOR_BUFFER), new File( + name: 'status.proto', + dependencies: [ + 'google/protobuf/timestamp.proto', + ], + messages: [ + new File\MessageDescriptor('google.rpc.Status', \Thesis\Google\Rpc\Status::class), + new File\MessageDescriptor('google.rpc.Status.Detail', \Thesis\Google\Rpc\Status\Detail::class), + ], + enums: [ + new File\EnumDescriptor('google.rpc.Status.Kind', \Thesis\Google\Rpc\Status\Kind::class), + ], + services: [ + new File\ServiceDescriptor( + name: 'google.rpc.StatusService', + methods: [ + new File\MethodDescriptor('Get', false, false), + ], + ), + ], + )); + } +} diff --git a/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/Status.php b/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/Status.php new file mode 100644 index 0000000..5f3d9bd --- /dev/null +++ b/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/Status.php @@ -0,0 +1,37 @@ + $invoke */ + $invoke = new Client\Invoke( + method: '/google.rpc.StatusService/Get', + output: \Thesis\Google\Rpc\Status::class, + type: Grpc\RpcType::Unary, + ); + + return $this->client->invoke( + request: $request, + invoke: $invoke, + md: $md, + cancellation: $cancellation, + ); + } +} diff --git a/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/StatusServiceServer.php b/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/StatusServiceServer.php new file mode 100644 index 0000000..9f0e4ad --- /dev/null +++ b/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/StatusServiceServer.php @@ -0,0 +1,28 @@ +server->get(...)), + type: Grpc\RpcType::Unary, + ), + ]); + } +} diff --git a/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/autoload.metadata.php b/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/autoload.metadata.php new file mode 100644 index 0000000..42cd186 --- /dev/null +++ b/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/autoload.metadata.php @@ -0,0 +1,14 @@ +register( + new \Thesis\Protobuf\Registry\OnceRegistrar(new \Thesis\Google\Rpc\DescriptorRegistry()), +); diff --git a/tests/snapshots/proto2/Proto/Api/V1/TestRequest.php b/tests/snapshots/proto2/Proto/Api/V1/TestRequest.php index a4c44f3..a29c3db 100644 --- a/tests/snapshots/proto2/Proto/Api/V1/TestRequest.php +++ b/tests/snapshots/proto2/Proto/Api/V1/TestRequest.php @@ -210,16 +210,16 @@ public function __construct( public \Proto\Api\V1\Foo $fooDefaulted = \Proto\Api\V1\Foo::FOO_BAR, #[Reflection\Field(406, new Reflection\MapT(Reflection\StringT::T, Reflection\StringT::T))] public Protobuf\Map $mapStringString = new Protobuf\Map(), - #[Reflection\Field(407, new Reflection\ObjectT(\Google\Protobuf\Timestamp::class))] - public ?\Google\Protobuf\Timestamp $knownTimestamp = null, - #[Reflection\Field(408, new Reflection\ObjectT(\Google\Protobuf\Duration::class))] - public ?\Google\Protobuf\Duration $knownDuration = null, - #[Reflection\Field(409, new Reflection\ObjectT(\Google\Protobuf\Struct::class))] - public ?\Google\Protobuf\Struct $knownStruct = null, - #[Reflection\Field(410, new Reflection\ObjectT(\Google\Protobuf\Empty_::class))] - public ?\Google\Protobuf\Empty_ $knownEmpty = null, - #[Reflection\Field(411, new Reflection\ObjectT(\Google\Protobuf\Any::class))] - public ?\Google\Protobuf\Any $knownAny = null, + #[Reflection\Field(407, new Reflection\ObjectT(\Thesis\Google\Protobuf\Timestamp::class))] + public ?\Thesis\Google\Protobuf\Timestamp $knownTimestamp = null, + #[Reflection\Field(408, new Reflection\ObjectT(\Thesis\Google\Protobuf\Duration::class))] + public ?\Thesis\Google\Protobuf\Duration $knownDuration = null, + #[Reflection\Field(409, new Reflection\ObjectT(\Thesis\Google\Protobuf\Struct::class))] + public ?\Thesis\Google\Protobuf\Struct $knownStruct = null, + #[Reflection\Field(410, new Reflection\ObjectT(\Thesis\Google\Protobuf\Empty_::class))] + public ?\Thesis\Google\Protobuf\Empty_ $knownEmpty = null, + #[Reflection\Field(411, new Reflection\ObjectT(\Thesis\Google\Protobuf\Any::class))] + public ?\Thesis\Google\Protobuf\Any $knownAny = null, #[Reflection\Field(412, new Reflection\ObjectT(\Proto\Api\V1\TestRequest\Nested::class))] public ?\Proto\Api\V1\TestRequest\Nested $nested = null, #[Reflection\Field(413, new Reflection\ObjectT(\Proto\Api\V1\TestRequest\Nested\Deep::class))] From f49e33a2e5797299ee33ab0bb7f8515cdacfe23e Mon Sep 17 00:00:00 2001 From: kafkiansky Date: Sun, 9 Aug 2026 11:07:12 +0300 Subject: [PATCH 2/5] chore: fix .gitattributes --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index 59a302b..2436a99 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,4 +10,5 @@ /CHANGELOG.md -export-ignore /composer.json -export-ignore /LICENSE -export-ignore +/map.json -export-ignore /README.md -export-ignore From e35a838cf75539ca3b099ad1d7ef1787904635e4 Mon Sep 17 00:00:00 2001 From: kafkiansky Date: Sun, 9 Aug 2026 11:17:15 +0300 Subject: [PATCH 3/5] chore: restore Dockerfile --- docker/php/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 49da3b9..db994a7 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -2,12 +2,13 @@ FROM alpine:3.20 AS protoc ARG PROTOC_VERSION=32.1 -RUN apk add --no-cache curl unzip && \ +RUN apk add --no-cache bash curl unzip && \ curl -LO "https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip" && \ unzip "protoc-${PROTOC_VERSION}-linux-x86_64.zip" -d /usr/local && \ rm "protoc-${PROTOC_VERSION}-linux-x86_64.zip" FROM ghcr.io/phpyh/php:8.4 +COPY --from=protoc /bin/bash /bin/bash COPY --from=protoc /usr/local/bin/protoc /usr/local/bin/protoc COPY --from=protoc /usr/local/include /usr/local/include From a0a6ce74370949a105b42b098792b3cc4bd98466 Mon Sep 17 00:00:00 2001 From: kafkiansky Date: Sun, 9 Aug 2026 12:09:50 +0300 Subject: [PATCH 4/5] chore: minor fixes --- src/Plugin/Mapping/TypeMap.php | 3 +-- tests/Plugin/Mapping/TypeMapTest.php | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Plugin/Mapping/TypeMap.php b/src/Plugin/Mapping/TypeMap.php index 3b49b77..ac9f2b4 100644 --- a/src/Plugin/Mapping/TypeMap.php +++ b/src/Plugin/Mapping/TypeMap.php @@ -58,7 +58,7 @@ public static function fromFile(string $file): self /** * @throws InvalidNamespaceMapping */ - public static function fromJson(string $json, string $source = 'namespace mapping'): self + public static function fromJson(string $json, string $source): self { try { $mapping = json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR); @@ -79,7 +79,6 @@ public static function fromJson(string $json, string $source = 'namespace mappin $parsed = []; $seen = []; - /** @var mixed $rule */ foreach ($rules as $index => $rule) { $parsed[] = $parsedRule = self::parseRule($rule, $source, $index); diff --git a/tests/Plugin/Mapping/TypeMapTest.php b/tests/Plugin/Mapping/TypeMapTest.php index 399a7db..cd1d4ef 100644 --- a/tests/Plugin/Mapping/TypeMapTest.php +++ b/tests/Plugin/Mapping/TypeMapTest.php @@ -104,7 +104,7 @@ public static function provideInvalidMappingCases(): iterable public function testDescriptionIsAllowed(): void { - $map = TypeMap::fromJson('{"rules": [{"from": "a.*", "to": "b.*"}]}'); + $map = TypeMap::fromJson('{"rules": [{"from": "a.*", "to": "b.*"}]}', 'test.json'); self::assertSame('b.C', $map->rewriteType('a.C')); } @@ -123,6 +123,7 @@ private static function map(): TypeMap ] } JSON, + 'test.json', ); } } From 13474ecfb3e8d6c8a65bb8b97e8978f404940a4f Mon Sep 17 00:00:00 2001 From: kafkiansky Date: Sun, 9 Aug 2026 12:54:57 +0300 Subject: [PATCH 5/5] chore: small mapping refactoring --- bin/compiler.php | 12 +----------- src/Plugin/Compiler.php | 11 ++++++++--- tests/CompilerTest.php | 5 +---- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/bin/compiler.php b/bin/compiler.php index 5e06121..b5962be 100755 --- a/bin/compiler.php +++ b/bin/compiler.php @@ -25,18 +25,8 @@ $encoder = Encoder\Builder::buildDefault(); $decoder = Decoder\Builder::buildDefault(); -try { - // The relocation table shipped with the plugin: it tells under which php namespaces - // the well known packages are generated and referenced. - $types = Plugin\Mapping\TypeMap::fromFile(__DIR__ . '/../map.json'); -} catch (Protoc\ProtocException $e) { - fwrite(STDERR, "{$e->getMessage()}\n"); - - exit(1); -} - $entrypoint = new Protoc\Entrypoint( - new Plugin\Compiler($encoder, $types), + new Plugin\Compiler($encoder, __DIR__ . '/../map.json'), $encoder, $decoder, ); diff --git a/src/Plugin/Compiler.php b/src/Plugin/Compiler.php index 7d8f0e0..d4e1983 100644 --- a/src/Plugin/Compiler.php +++ b/src/Plugin/Compiler.php @@ -28,9 +28,12 @@ private Parser $parser; + /** + * @param non-empty-string $mappingPath + */ public function __construct( private Encoder $encoder, - private Mapping\TypeMap $types, + private string $mappingPath, ) { $this->parser = new Parser(); } @@ -58,9 +61,11 @@ private function doGenerate( CodeGeneratorRequest $request, CompilerOptions $options, ): iterable { + $types = Mapping\TypeMap::fromFile($this->mappingPath); + $request = $this->parser->parse($request); - $namespaces = new NamespaceResolver($this->types, $options); + $namespaces = new NamespaceResolver($types, $options); $registry = new Dependency\Registry($request, $namespaces); @@ -87,7 +92,7 @@ private function doGenerate( package: $proto->package, syntax: $proto->syntax, edition: $proto->file->edition, - types: $this->types, + types: $types, ); foreach ($proto->services as $service) { diff --git a/tests/CompilerTest.php b/tests/CompilerTest.php index 0c64538..c62f112 100644 --- a/tests/CompilerTest.php +++ b/tests/CompilerTest.php @@ -12,7 +12,6 @@ use Thesis\Protobuf\Decoder; use Thesis\Protobuf\Encoder; use Thesis\Protoc\Plugin\Compiler; -use Thesis\Protoc\Plugin\Mapping\TypeMap; #[CoversClass(Compiler::class)] final class CompilerTest extends TestCase @@ -31,9 +30,7 @@ public function testCompile(string $file): void $request = $decoder->decode($bytes, CodeGeneratorRequest::class); - $types = TypeMap::fromFile(__DIR__ . '/../map.json'); - - $actual = self::collectFiles(new Compiler($encoder, $types)->compile($request)); + $actual = self::collectFiles(new Compiler($encoder, __DIR__ . '/../map.json')->compile($request)); $expected = self::collectSnapshots(__DIR__ . '/snapshots/' . \dirname($file)); self::assertSame(