From fa85870db50cb7c44c9a36d6f1caed005a206d41 Mon Sep 17 00:00:00 2001
From: Adam G-H <32250-phenaproxima@users.noreply.drupalcode.org>
Date: Wed, 19 Jul 2023 17:00:01 +0000
Subject: [PATCH] Issue #3368808 by phenaproxima: Override Composer Stager's
 TranslatableFactory to return Drupal's TranslatableMarkup

---
 package_manager/package_manager.install       |  8 ++-
 package_manager/package_manager.services.yml  |  4 ++
 .../src/PackageManagerServiceProvider.php     |  1 +
 .../src/TranslatableStringAdapter.php         | 53 +++++++++++++++
 .../src/TranslatableStringFactory.php         | 65 +++++++++++++++++++
 package_manager/src/ValidationResult.php      |  6 +-
 .../src/Kernel/ComposerInspectorTest.php      |  5 +-
 .../tests/src/Kernel/ServicesTest.php         |  3 +
 .../tests/src/Kernel/SymlinkValidatorTest.php | 34 ++++++----
 .../src/Kernel/TranslatableStringTest.php     | 41 ++++++++++++
 10 files changed, 206 insertions(+), 14 deletions(-)
 create mode 100644 package_manager/src/TranslatableStringAdapter.php
 create mode 100644 package_manager/src/TranslatableStringFactory.php
 create mode 100644 package_manager/tests/src/Kernel/TranslatableStringTest.php

diff --git a/package_manager/package_manager.install b/package_manager/package_manager.install
index 303e3de373..d022e7f019 100644
--- a/package_manager/package_manager.install
+++ b/package_manager/package_manager.install
@@ -10,6 +10,7 @@ declare(strict_types = 1);
 use Drupal\package_manager\ComposerInspector;
 use Drupal\package_manager\Exception\StageFailureMarkerException;
 use PhpTuf\ComposerStager\API\Core\BeginnerInterface;
+use PhpTuf\ComposerStager\API\Exception\ExceptionInterface;
 use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface;
 
 /**
@@ -62,10 +63,15 @@ function package_manager_requirements(string $phase) {
     ];
   }
   catch (\Throwable $e) {
+    // All Composer Stager exceptions are translatable.
+    $message = $e instanceof ExceptionInterface
+      ? $e->getTranslatableMessage()
+      : $e->getMessage();
+
     $requirements['package_manager_composer'] = [
       'title' => $title,
       'description' => t('Composer was not found. The error message was: @message', [
-        '@message' => $e->getMessage(),
+        '@message' => $message,
       ]),
       'severity' => REQUIREMENT_ERROR,
     ];
diff --git a/package_manager/package_manager.services.yml b/package_manager/package_manager.services.yml
index 43e0aa906b..23d28fe557 100644
--- a/package_manager/package_manager.services.yml
+++ b/package_manager/package_manager.services.yml
@@ -16,12 +16,16 @@ services:
     public: false
   Drupal\package_manager\FileSyncerFactory:
     public: false
+  Drupal\package_manager\TranslatableStringFactory:
+    public: false
   PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface:
     alias: 'Drupal\package_manager\ExecutableFinder'
   PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface:
     alias: 'Drupal\package_manager\ProcessFactory'
   PhpTuf\ComposerStager\API\FileSyncer\Service\FileSyncerInterface:
     factory: ['@Drupal\package_manager\FileSyncerFactory', 'create']
+  PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface:
+    alias: 'Drupal\package_manager\TranslatableStringFactory'
   logger.channel.package_manager:
     parent: logger.channel_base
     arguments:
diff --git a/package_manager/src/PackageManagerServiceProvider.php b/package_manager/src/PackageManagerServiceProvider.php
index ba31f9985b..3caf33e14a 100644
--- a/package_manager/src/PackageManagerServiceProvider.php
+++ b/package_manager/src/PackageManagerServiceProvider.php
@@ -111,6 +111,7 @@ final class PackageManagerServiceProvider extends ServiceProviderBase {
       'theme_handler' => 'Drupal\Core\Extension\ThemeHandlerInterface',
       'cron' => 'Drupal\Core\CronInterface',
       'logger.factory' => 'Drupal\Core\Logger\LoggerChannelFactoryInterface',
+      'string_translation' => 'Drupal\Core\StringTranslation\TranslationInterface',
     ];
     foreach ($aliases as $service_id => $alias) {
       if (!$container->hasAlias($alias)) {
diff --git a/package_manager/src/TranslatableStringAdapter.php b/package_manager/src/TranslatableStringAdapter.php
new file mode 100644
index 0000000000..9db2f5ac95
--- /dev/null
+++ b/package_manager/src/TranslatableStringAdapter.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\package_manager;
+
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use PhpTuf\ComposerStager\API\Translation\Service\TranslatorInterface;
+use PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface;
+use PhpTuf\ComposerStager\API\Translation\Value\TranslationParametersInterface;
+
+/**
+ * An adapter for interoperable string translation.
+ *
+ * This class is designed to adapt Drupal's style of string translation so it
+ * can be used with the Symfony-inspired architecture used by Composer Stager.
+ *
+ * If this object is cast to a string, it will be translated by Drupal's
+ * translation system. It will ONLY be translated by Composer Stager if the
+ * trans() method is explicitly called.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+final class TranslatableStringAdapter extends TranslatableMarkup implements TranslatableInterface, TranslationParametersInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAll(): array {
+    return $this->getArguments();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function trans(TranslatorInterface $translator, ?string $locale = NULL): string {
+    // This method is NEVER used by Drupal to translate the underlying string;
+    // it exists solely for Composer Stager's translation system to
+    // transparently translate Drupal strings using its own architecture.
+    return $translator->trans(
+      $this->getUntranslatedString(),
+      $this,
+      // The 'context' option is the closest analogue to the Symfony-inspired
+      // concept of translation domains.
+      $this->getOption('context'),
+      $locale ?? $this->getOption('langcode'),
+    );
+  }
+
+}
diff --git a/package_manager/src/TranslatableStringFactory.php b/package_manager/src/TranslatableStringFactory.php
new file mode 100644
index 0000000000..8b2f6a2282
--- /dev/null
+++ b/package_manager/src/TranslatableStringFactory.php
@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\package_manager;
+
+use Drupal\Core\StringTranslation\TranslationInterface;
+use PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface;
+use PhpTuf\ComposerStager\API\Translation\Service\DomainOptionsInterface;
+use PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface;
+use PhpTuf\ComposerStager\API\Translation\Value\TranslationParametersInterface;
+use PhpTuf\ComposerStager\Internal\Translation\Factory\TranslatableFactory as StagerTranslatableFactory;
+
+/**
+ * Creates translatable strings that can interoperate with Composer Stager.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+final class TranslatableStringFactory implements TranslatableFactoryInterface {
+
+  /**
+   * Constructs a TranslatableStringFactory object.
+   *
+   * @param \PhpTuf\ComposerStager\Internal\Translation\Factory\TranslatableFactory $decorated
+   *   The decorated translatable factory service.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The string translation service.
+   */
+  public function __construct(
+    private readonly StagerTranslatableFactory $decorated,
+    private readonly TranslationInterface $translation,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function createDomainOptions(): DomainOptionsInterface {
+    return $this->decorated->createDomainOptions();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function createTranslatableMessage(string $message, ?TranslationParametersInterface $parameters = NULL, ?string $domain = NULL,): TranslatableInterface {
+    return new TranslatableStringAdapter(
+      $message,
+      $parameters?->getAll() ?? [],
+      // TranslatableMarkup's 'context' option is the closest analogue to the
+      // $domain parameter.
+      ['context' => $domain ?? ''],
+      $this->translation,
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function createTranslationParameters(array $parameters = []): TranslationParametersInterface {
+    return $this->decorated->createTranslationParameters($parameters);
+  }
+
+}
diff --git a/package_manager/src/ValidationResult.php b/package_manager/src/ValidationResult.php
index a9854a075a..3292a4a9ec 100644
--- a/package_manager/src/ValidationResult.php
+++ b/package_manager/src/ValidationResult.php
@@ -7,6 +7,7 @@ namespace Drupal\package_manager;
 use Drupal\Component\Assertion\Inspector;
 use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\system\SystemManager;
+use PhpTuf\ComposerStager\API\Exception\ExceptionInterface;
 
 /**
  * A value object to contain the results of a validation.
@@ -72,7 +73,10 @@ final class ValidationResult {
    * @return static
    */
   public static function createErrorFromThrowable(\Throwable $throwable, ?TranslatableMarkup $summary = NULL): static {
-    return new static(SystemManager::REQUIREMENT_ERROR, [$throwable->getMessage()], $summary, FALSE);
+    // All Composer Stager exceptions are translatable.
+    $is_translatable = $throwable instanceof ExceptionInterface;
+    $message = $is_translatable ? $throwable->getTranslatableMessage() : $throwable->getMessage();
+    return new static(SystemManager::REQUIREMENT_ERROR, [$message], $summary, $is_translatable);
   }
 
   /**
diff --git a/package_manager/tests/src/Kernel/ComposerInspectorTest.php b/package_manager/tests/src/Kernel/ComposerInspectorTest.php
index 5d4c0de288..ee3106a38e 100644
--- a/package_manager/tests/src/Kernel/ComposerInspectorTest.php
+++ b/package_manager/tests/src/Kernel/ComposerInspectorTest.php
@@ -318,7 +318,10 @@ class ComposerInspectorTest extends PackageManagerKernelTestBase {
     }
     catch (ComposerNotReadyException $e) {
       $this->assertSame($project_root, $e->workingDir);
-      $this->assertStringContainsString('composer.json" does not match the expected JSON schema', $e->getMessage());
+      // The exception message is translated by Composer Stager and HTML-escaped
+      // by Drupal's markup system, which is why there's a &quot; in the
+      // final exception message.
+      $this->assertStringContainsString('composer.json&quot; does not match the expected JSON schema', $e->getMessage());
       $this->assertStringContainsString('prefer-stable : String value found, but a boolean is required', $e->getPrevious()?->getMessage());
     }
   }
diff --git a/package_manager/tests/src/Kernel/ServicesTest.php b/package_manager/tests/src/Kernel/ServicesTest.php
index 26862671b2..862af87396 100644
--- a/package_manager/tests/src/Kernel/ServicesTest.php
+++ b/package_manager/tests/src/Kernel/ServicesTest.php
@@ -7,9 +7,11 @@ namespace Drupal\Tests\package_manager\Kernel;
 use Drupal\KernelTests\KernelTestBase;
 use Drupal\package_manager\ExecutableFinder;
 use Drupal\package_manager\ProcessFactory;
+use Drupal\package_manager\TranslatableStringFactory;
 use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait;
 use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface;
 use PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface;
+use PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface;
 
 /**
  * Tests that Package Manager services are wired correctly.
@@ -44,6 +46,7 @@ class ServicesTest extends KernelTestBase {
     $overrides = [
       ExecutableFinderInterface::class => ExecutableFinder::class,
       ProcessFactoryInterface::class => ProcessFactory::class,
+      TranslatableFactoryInterface::class => TranslatableStringFactory::class,
     ];
     foreach ($overrides as $interface => $expected_class) {
       $this->assertInstanceOf($expected_class, $this->container->get($interface));
diff --git a/package_manager/tests/src/Kernel/SymlinkValidatorTest.php b/package_manager/tests/src/Kernel/SymlinkValidatorTest.php
index 1f59dc4c8a..bd7de89489 100644
--- a/package_manager/tests/src/Kernel/SymlinkValidatorTest.php
+++ b/package_manager/tests/src/Kernel/SymlinkValidatorTest.php
@@ -47,8 +47,10 @@ class SymlinkValidatorTest extends PackageManagerKernelTestBase {
 
     link($project_root . '/composer.json', $project_root . '/composer.link');
     $result = ValidationResult::createError([
-      t('The active directory at @dir contains hard links, which is not supported. The first one is @dir/composer.json.', [
-        '@dir' => $project_root,
+      t('The %which directory at %dir contains hard links, which is not supported. The first one is %file.', [
+        '%which' => 'active',
+        '%dir' => $project_root,
+        '%file' => $project_root . '/composer.json',
       ]),
     ]);
     $this->assertStatusCheckResults([$result]);
@@ -63,8 +65,10 @@ class SymlinkValidatorTest extends PackageManagerKernelTestBase {
 
     symlink($project_root . '/composer.json', $project_root . '/composer.link');
     $result = ValidationResult::createError([
-      t('The active directory at @dir contains absolute links, which is not supported. The first one is @dir/composer.link.', [
-        '@dir' => $project_root,
+      t('The %which directory at %dir contains absolute links, which is not supported. The first one is %file.', [
+        '%which' => 'active',
+        '%dir' => $project_root,
+        '%file' => $project_root . '/composer.link',
       ]),
     ]);
     $this->assertStatusCheckResults([$result]);
@@ -84,8 +88,10 @@ class SymlinkValidatorTest extends PackageManagerKernelTestBase {
     chdir($project_root);
     symlink('../hello.txt', 'fail.txt');
     $result = ValidationResult::createError([
-      t('The active directory at @dir contains links that point outside the codebase, which is not supported. The first one is @dir/fail.txt.', [
-        '@dir' => $project_root,
+      t('The %which directory at %dir contains links that point outside the codebase, which is not supported. The first one is %file.', [
+        '%which' => 'active',
+        '%dir' => $project_root,
+        '%file' => $project_root . '/fail.txt',
       ]),
     ]);
     $this->assertStatusCheckResults([$result]);
@@ -111,8 +117,10 @@ class SymlinkValidatorTest extends PackageManagerKernelTestBase {
     symlink('../hello.txt', 'fail.txt');
 
     $result = ValidationResult::createError([
-      t('The staging directory at @dir contains links that point outside the codebase, which is not supported. The first one is @dir/fail.txt.', [
-        '@dir' => $stage_dir,
+      t('The %which directory at %dir contains links that point outside the codebase, which is not supported. The first one is %file.', [
+        '%which' => 'staging',
+        '%dir' => $stage_dir,
+        '%file' => $stage_dir . '/fail.txt',
       ]),
     ]);
     try {
@@ -136,7 +144,9 @@ class SymlinkValidatorTest extends PackageManagerKernelTestBase {
         'php',
         [
           ValidationResult::createError([
-            t('The active directory at <PROJECT_ROOT> contains symlinks that point to a directory, which is not supported. The first one is <PROJECT_ROOT>/modules/custom/example_module.'),
+            t('The %which directory at <em class="placeholder"><PROJECT_ROOT></em> contains symlinks that point to a directory, which is not supported. The first one is <em class="placeholder"><PROJECT_ROOT>/modules/custom/example_module</em>.', [
+              '%which' => 'active',
+            ]),
           ]),
         ],
       ],
@@ -197,8 +207,10 @@ class SymlinkValidatorTest extends PackageManagerKernelTestBase {
     symlink('composer.json', 'composer.link');
 
     $result = ValidationResult::createError([
-      t('The active directory at @dir contains links, which is not supported on Windows. The first one is @dir/composer.link.', [
-        '@dir' => $project_root,
+      t('The %which directory at %dir contains links, which is not supported on Windows. The first one is %file.', [
+        '%which' => 'active',
+        '%dir' => $project_root,
+        '%file' => $project_root . '/composer.link',
       ]),
     ]);
     $this->assertStatusCheckResults([$result]);
diff --git a/package_manager/tests/src/Kernel/TranslatableStringTest.php b/package_manager/tests/src/Kernel/TranslatableStringTest.php
new file mode 100644
index 0000000000..d05e3043fe
--- /dev/null
+++ b/package_manager/tests/src/Kernel/TranslatableStringTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\package_manager\TranslatableStringAdapter;
+use Drupal\package_manager\TranslatableStringFactory;
+use PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface;
+
+/**
+ * @covers \Drupal\package_manager\TranslatableStringFactory
+ * @covers \Drupal\package_manager\TranslatableStringAdapter
+ *
+ * @group package_manager
+ */
+class TranslatableStringTest extends PackageManagerKernelTestBase {
+
+  /**
+   * Tests various ways of creating a translatable string.
+   */
+  public function testCreateTranslatableString(): void {
+    // Ensure that we have properly overridden Composer Stager's factory.
+    $factory = $this->container->get(TranslatableFactoryInterface::class);
+    $this->assertInstanceOf(TranslatableStringFactory::class, $factory);
+
+    /** @var \Drupal\package_manager\TranslatableStringAdapter $string */
+    $string = $factory->createTranslatableMessage('This string has no parameters.');
+    $this->assertInstanceOf(TranslatableStringAdapter::class, $string);
+    $this->assertEmpty($string->getArguments());
+    $this->assertEmpty($string->getOption('context'));
+    $this->assertSame('This string has no parameters.', (string) $string);
+
+    $parameters = $factory->createTranslationParameters([
+      '%name' => 'Slim Shady',
+    ]);
+    $string = $factory->createTranslatableMessage('My name is %name.', $parameters, 'outer space');
+    $this->assertSame($parameters->getAll(), $string->getArguments());
+    $this->assertSame('outer space', $string->getOption('context'));
+    $this->assertSame('My name is <em class="placeholder">Slim Shady</em>.', (string) $string);
+  }
+
+}
-- 
GitLab