diff --git a/.github/workflows/php_unit.yml b/.github/workflows/php_unit.yml index 997c762..f168225 100644 --- a/.github/workflows/php_unit.yml +++ b/.github/workflows/php_unit.yml @@ -102,17 +102,28 @@ jobs: cp phpcs.xml dist/phpcs.xml cp phpunit.xml dist/phpunit.xml cp -r tests dist/tests + cp -r utils dist/utils - name: Install Dist Composer Dependencies if: matrix.php != '8.5' working-directory: dist run: composer install --no-interaction --no-progress --no-suggest --optimize-autoloader + - name: Build Conflict Plugin + if: matrix.php != '8.5' + working-directory: dist + run: npm run build-conflict + - name: Start Dist Docker environment if: matrix.php != '8.5' working-directory: dist run: npm run wp-env start + - name: Install Conflict Plugin + if: matrix.php != '8.5' + working-directory: dist + run: npm run install-conflict + - name: Log running Dist Docker containers if: matrix.php != '8.5' working-directory: dist diff --git a/.wp-env.json b/.wp-env.json index 80bbdbf..326887d 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -1,18 +1,19 @@ { - "core": "WordPress/WordPress", - "plugins": [ "." ], - "config": { + "core": "WordPress/WordPress", + "plugins": [ "." ], + "config": { "WP_ENVIRONMENT_TYPE": "production", "WP_ENV": "production", - "WP_DEBUG": true, - "WP_DEBUG_LOG": true, - "WP_DEBUG_DISPLAY": true - }, - "env": { - "tests": { - "mappings": { - "wp-content/plugins/rollbar": "./" - } - } - } + "WP_DEBUG": true, + "WP_DEBUG_LOG": true, + "WP_DEBUG_DISPLAY": true + }, + "env": { + "tests": { + "mappings": { + "wp-content/plugins/rollbar": "./", + "wp-content/plugins/conflict": "./tests/conflict" + } + } + } } \ No newline at end of file diff --git a/composer.json b/composer.json index 1173788..26b9486 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,8 @@ }, "autoload-dev": { "psr-4": { - "Rollbar\\WordPress\\Tests\\": "tests/" + "Rollbar\\WordPress\\Tests\\": "tests/", + "Rollbar\\WordPress\\BuildUtils\\": "utils/" } }, "extra": { diff --git a/package-lock.json b/package-lock.json index 1ada56b..ac1ec24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -527,7 +527,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } diff --git a/package.json b/package.json index a4f12ed..9989549 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,12 @@ "test": "npm-run-all test:php test:phpcs", "tests-composer-update": "WP_ENV_PHP_VERSION='8.1' npm run wp-env -- run --env-cwd='wp-content/plugins/rollbar' tests-cli composer update", "tests-composer-install": "WP_ENV_PHP_VERSION='8.1' npm run wp-env -- run --env-cwd='wp-content/plugins/rollbar' tests-cli composer install --no-interaction", - "pretest:php": "npm-run-all tests-composer-install", "test:php": "npm run wp-env -- run tests-wordpress /var/www/html/wp-content/plugins/rollbar/vendor/bin/phpunit -c /var/www/html/wp-content/plugins/rollbar/phpunit.xml", "test:phpcs": "npm run wp-env -- run tests-wordpress /var/www/html/wp-content/plugins/rollbar/vendor/bin/phpcs --standard=/var/www/html/wp-content/plugins/rollbar/phpcs.xml -p", "start": "npm run wp-env start", - "build": "cp -f node_modules/rollbar/dist/rollbar.snippet.js public/js/rollbar.snippet.js" + "build": "cp -f node_modules/rollbar/dist/rollbar.snippet.js public/js/rollbar.snippet.js", + "build-conflict": "cd tests/conflict && composer install --no-interaction --no-progress --no-suggest --optimize-autoloader", + "install-conflict": "npm run wp-env -- run tests-cli wp plugin activate conflict" }, "repository": { "type": "git", diff --git a/phpcs.xml b/phpcs.xml index b4537fa..575cd8d 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -13,6 +13,7 @@ ./mu-plugin ./src ./tests + ./utils ./rollbar.php diff --git a/scoper.inc.php b/scoper.inc.php index d6e239e..09047a3 100644 --- a/scoper.inc.php +++ b/scoper.inc.php @@ -2,7 +2,10 @@ declare(strict_types=1); +require_once './utils/NamespaceTokenFixer.php'; + $finder = Isolated\Symfony\Component\Finder\Finder::class; +$fixer = new Rollbar\WordPress\BuildUtils\NamespaceTokenFixer(); return [ 'prefix' => 'RollbarWP', @@ -46,14 +49,10 @@ 'zend_monitor_custom_event', ], 'patchers' => [ - static function (string $filePath, string $prefix, string $content): string { - // Fix ClassLoader in string not being prefixed. - if ($filePath === __DIR__ . '/build/vendor/composer/autoload_real.php') { - $content = str_replace( - '(\'Composer\\\\Autoload\\\\ClassLoader\' === $class)', - '(\'RollbarWP\Composer\Autoload\ClassLoader\' === $class)', - $content, - ); + static function (string $filePath, string $prefix, string $content) use ($fixer): string { + if ($fixer->fileIsFixable($filePath)) { + $fixer->configure($filePath, $content); + $content = $fixer->fix(); } return $content; diff --git a/tests/conflict/composer.json b/tests/conflict/composer.json new file mode 100644 index 0000000..3a1deb6 --- /dev/null +++ b/tests/conflict/composer.json @@ -0,0 +1,15 @@ +{ + "name": "conflict/conflict", + "description": "This is a test plugin that has dependency conflicts.", + "minimum-stability": "stable", + "license": "MIT", + "require": { + "psr/log": "^1.0", + "monolog/monolog": "^1.0" + }, + "autoload": { + "psr-4": { + "Conflict\\": "src/" + } + } +} \ No newline at end of file diff --git a/tests/conflict/composer.lock b/tests/conflict/composer.lock new file mode 100644 index 0000000..892b75b --- /dev/null +++ b/tests/conflict/composer.lock @@ -0,0 +1,155 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "7526d404b95e868fb43368e3813b71de", + "packages": [ + { + "name": "monolog/monolog", + "version": "1.27.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "904713c5929655dc9b97288b69cfeedad610c9a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/904713c5929655dc9b97288b69cfeedad610c9a1", + "reference": "904713c5929655dc9b97288b69cfeedad610c9a1", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "psr/log": "~1.0" + }, + "provide": { + "psr/log-implementation": "1.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^2.4.9 || ^3.0", + "doctrine/couchdb": "~1.0@dev", + "graylog2/gelf-php": "~1.0", + "php-amqplib/php-amqplib": "~2.4", + "php-console/php-console": "^3.1.3", + "phpstan/phpstan": "^0.12.59", + "phpunit/phpunit": "~4.5", + "ruflin/elastica": ">=0.90 <3.0", + "sentry/sentry": "^0.13", + "swiftmailer/swiftmailer": "^5.3|^6.0" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-mongo": "Allow sending log messages to a MongoDB server", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server via PHP Driver", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "php-console/php-console": "Allow sending log messages to Google Chrome", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server", + "sentry/sentry": "Allow sending log messages to a Sentry server" + }, + "type": "library", + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "http://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/1.27.1" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2022-06-09T08:53:42+00:00" + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/1.1.4" + }, + "time": "2021-05-03T11:20:27+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/tests/conflict/conflict.php b/tests/conflict/conflict.php new file mode 100644 index 0000000..784cda1 --- /dev/null +++ b/tests/conflict/conflict.php @@ -0,0 +1,24 @@ +pushHandler(new StreamHandler(WP_CONTENT_DIR . '/logs/conflict.log', Logger::WARNING)); + + add_action('init', self::onInit(...)); + } + + /** + * Logs when the plugin is initialized. + * + * @return void + */ + public static function onInit(): void + { + self::$logger->warning('Conflict plugin initialized.'); + } +} diff --git a/tests/utils/NamespaceTokenFixerTest.php b/tests/utils/NamespaceTokenFixerTest.php new file mode 100644 index 0000000..84fa573 --- /dev/null +++ b/tests/utils/NamespaceTokenFixerTest.php @@ -0,0 +1,87 @@ +subject = new NamespaceTokenFixer(); + } + + public function testFixUnscopedNamespaceStringsNoOp(): void + { + $input = 'subject->configure('./build/vendor/composer/autoload_psr4.php', $input); + + self::assertSame($input, $this->subject->fix()); + } + + public function testFixUnscopedNamespaceStringsUnprefixed(): void + { + // Because we have a string within a string, we need two levels of escaping. It is ugly, but it produces the + // equivalent of the following PHP code: + // subject->configure('./build/vendor/composer/autoload_psr4.php', $input); + + self::assertSame('subject->fix()); + } + + public function testFixUnscopedNamespaceStringsPrefixed(): void + { + $input = 'subject->configure('./build/vendor/composer/autoload_psr4.php', $input); + + self::assertSame('subject->fix()); + } + + public function testFixUnscopedNamespaceStringsInvalidFile(): void + { + $input = 'subject->configure('foo.php', $input); + + self::assertSame('subject->fix()); + } + + public function testFixAutoloadStaticPrefixLengthsPsr4(): void + { + $input = ' array(\'Rollbar\\\\WordPress\\\\\' => 18, \'Rollbar\\\\\' => 8), + \'P\' => array(\'Psr\\\\Log\\\\\' => 8), + \'M\' => array(\'Monolog\\\\\' => 8), + ); + $foo = 42;'; + + $expected = ' array( + \'Rollbar\\\\WordPress\\\\\' => 18, + \'Rollbar\\\\\' => 8, + \'RollbarWP\\\\Psr\\\\Log\\\\\' => 18, + \'RollbarWP\\\\Monolog\\\\\' => 18, + ), + ); + $foo = 42;'; + + $this->subject->configure('./build/vendor/composer/autoload_static.php', $input); + + self::assertSame(self::normalizeString($expected), self::normalizeString($this->subject->fix())); + } + + /** + * Removes all whitespace from a string. + * + * @param string $string The string to normalize. + * @return string + */ + private static function normalizeString(string $string): string + { + return str_replace([' ', "\n", "\r", "\t"], '', $string); + } +} diff --git a/utils/NamespaceTokenFixer.php b/utils/NamespaceTokenFixer.php new file mode 100644 index 0000000..4a528e6 --- /dev/null +++ b/utils/NamespaceTokenFixer.php @@ -0,0 +1,213 @@ +tokens = PhpToken::tokenize($contents); + } + + /** + * Configures the file to fix. + * + * @param string $filename The path and filename of the file to fix. + * @param string $contents The contents of the file to fix. + * @return void + */ + public function configure(string $filename, string $contents): void + { + $this->filename = $filename; + $this->tokens = PhpToken::tokenize($contents); + } + + /** + * Checks if the file is fixable. + * + * @param string $path The path to the file. + * @return bool + */ + public function fileIsFixable(string $path): bool + { + foreach ($this->fixes() as $fixablePath => $fixes) { + if (str_ends_with($path, $fixablePath)) { + return true; + } + } + + return false; + } + + /** + * Fixes the file. + * + * @return string The fixed contents of the file. + */ + public function fix(): string + { + foreach ($this->fixes() as $fixablePath => $fixFunctions) { + if (str_ends_with($this->filename, $fixablePath)) { + foreach ($fixFunctions as $fixFunction) { + $fixFunction(); + } + } + } + + return implode('', $this->tokens); + } + + /** + * Returns an array of fixes for the file. + * + * @return array> + */ + private function fixes(): array + { + return [ + '/build/vendor/composer/autoload_static.php' => [ + $this->fixAutoloadStaticPrefixLengthsPsr4(...), + $this->fixUnscopedNamespaceStrings(...), + ], + '/build/vendor/composer/autoload_real.php' => [ + $this->fixUnscopedNamespaceStrings(...), + ], + '/build/vendor/composer/autoload_psr4.php' => [ + $this->fixUnscopedNamespaceStrings(...), + ], + ]; + } + + /** + * Fixes the `$prefixLengthsPsr4` property in the `autoload_static.php` file. + * + * @return void + * + * @throws RuntimeException If the `$prefixLengthsPsr4` variable could not be found. + */ + private function fixAutoloadStaticPrefixLengthsPsr4(): void + { + // Collect all tokens belonging to the `$prefixLengthsPsr4` variable expression. + $startIndex = -1; + $endIndex = -1; + $tokens = []; + foreach ($this->tokens as $i => $token) { + if (T_VARIABLE === $token->id && '$prefixLengthsPsr4' === (string)$token && -1 === $startIndex) { + $startIndex = $i; + continue; + } + if (-1 !== $startIndex && -1 === $endIndex) { + if (59 === $token->id) { // ";" character + $endIndex = $i; + continue; + } + $tokens[] = $token; + } + } + + if (-1 === $endIndex) { + throw new RuntimeException('Could not find $prefixLengthsPsr4 variable in autoload_static.php'); + } + + // Extract all namespace strings from the tokens. + $namespaces = []; + foreach ($tokens as $token) { + // + if (T_CONSTANT_ENCAPSED_STRING !== $token->id) { + continue; + } + $string = (string)$token; + // The first level keys of the array are always single characters (such as 'R' for 'Rollbar\'). Skip them. + if (strlen($string) < 3) { + continue; + } + // Remove quotes from the token string. + $string = substr($token, 1, -1); + // Skip strings that don't contain a backslash. + if (!str_contains($string, '\\')) { + continue; + } + // Fix escaped backslashes. + $string = str_replace('\\\\', '\\', $string); + + // Prepend "RollbarWP\\" to all namespace strings that are not already correctly prefixed. + if (!str_starts_with($string, 'Rollbar\\') && !str_starts_with($string, 'RollbarWP\\')) { + $string = 'RollbarWP\\' . $string; + } + $namespaces[] = $string; + } + + $prefixLengthsPsr4 = []; + + foreach ($namespaces as $namespace) { + $firstChar = substr($namespace, 0, 1); + $prefixLengthsPsr4[$firstChar][$namespace] = strlen($namespace); + } + + $result = var_export($prefixLengthsPsr4, true); + + $newTokens = PhpToken::tokenize('tokens, + $startIndex, + $endIndex - $startIndex + 1, + $newTokens, + ); + } + + /** + * Fixes unscoped namespace strings in the file. + * + * @return void + */ + private function fixUnscopedNamespaceStrings(): void + { + foreach ($this->tokens as $i => $token) { + if (T_CONSTANT_ENCAPSED_STRING !== $token->id) { + continue; + } + // Cast the token to a string to get the token value. + $string = (string)$token; + // Remove quotes from the token string. + $string = substr($string, 1, -1); + if (!str_contains($string, '\\')) { + continue; + } + // Skip strings that are already prefixed. + if (str_starts_with($string, 'Rollbar\\') || str_starts_with($string, 'RollbarWP\\')) { + continue; + } + // Ensure the first character is uppercase. + $firstChar = substr($string, 0, 1); + if (!ctype_upper($firstChar)) { + continue; + } + $newString = '\'RollbarWP\\' . $string . '\''; + $this->tokens[$i] = new PhpToken(T_CONSTANT_ENCAPSED_STRING, $newString, $token->line, $token->pos); + } + } +}