Commit 343ccdf8 authored by Gábor Hojtsy's avatar Gábor Hojtsy
Browse files

Issue #3111022 by Gábor Hojtsy, lauriii: Detect use of theme functions

parent 1ba55cea
Loading
Loading
Loading
Loading
+2 −1
Changes for composer.json: 2 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -9,7 +9,8 @@
        "mglaman/phpstan-drupal": "^0.12.0",
        "phpstan/phpstan-deprecation-rules": "^0.12.0",
        "drupal/git_deploy": "2.x-dev",
        "mathieuviossat/arraytotexttable": "~1.0.0"
        "mathieuviossat/arraytotexttable": "~1.0.0",
        "nikic/php-parser": "^4.0.0"
    },
    "minimum-stability": "dev",
    "extra": {
+23 −2
Changes for src/DeprecationAnalyzer.php: 23 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -82,6 +82,13 @@ final class DeprecationAnalyzer {
   */
  protected $libraryDeprecationAnalyzer;

  /**
   * The theme function deprecation analyzer.
   *
   * @var \Drupal\upgrade_status\ThemeFunctionDeprecationAnalyzer
   */
  protected $themeFunctionDeprecationAnalyzer;

  /**
   * Constructs a deprecation analyzer.
   *
@@ -97,6 +104,8 @@ final class DeprecationAnalyzer {
   *   The Twig environment.
   * @param \Drupal\upgrade_status\LibraryDeprecationAnalyzer
   *   The library deprecation analyzer.
   * @param \Drupal\upgrade_status\ThemeFunctionDeprecationAnalyzer
   *   The theme function deprecation analyzer.
   */
  public function __construct(
    KeyValueFactoryInterface $key_value_factory,
@@ -104,7 +113,8 @@ final class DeprecationAnalyzer {
    Client $http_client,
    FileSystemInterface $file_system,
    TwigEnvironment $twig_environment,
    LibraryDeprecationAnalyzer $library_deprecation_analyzer
    LibraryDeprecationAnalyzer $library_deprecation_analyzer,
    ThemeFunctionDeprecationAnalyzer $theme_function_deprecation_analyzer
  ) {
    $this->scanResultStorage = $key_value_factory->get('upgrade_status_scan_results');
    // Log errors to an upgrade status logger channel.
@@ -113,6 +123,7 @@ final class DeprecationAnalyzer {
    $this->fileSystem = $file_system;
    $this->twigEnvironment = $twig_environment;
    $this->libraryDeprecationAnalyzer = $library_deprecation_analyzer;
    $this->themeFunctionDeprecationAnalyzer = $theme_function_deprecation_analyzer;

    $this->vendorPath = $this->findVendorPath();

@@ -186,6 +197,16 @@ final class DeprecationAnalyzer {
      $result['data']['totals']['file_errors']++;
    }

    $theme_function_deprecations = $this->themeFunctionDeprecationAnalyzer->analyze($extension);
    foreach ($theme_function_deprecations as $deprecation_message) {
      $result['data']['files'][$deprecation_message->getFile()]['messages'][] = [
        'message' => $deprecation_message->getMessage(),
        'line' => $deprecation_message->getLine(),
      ];
      $result['data']['totals']['errors']++;
      $result['data']['totals']['file_errors']++;
    }

    // Manually add on info file incompatibility to results.
    $info = $extension->info;
    if (!isset($info['core_version_requirement'])) {
@@ -239,7 +260,7 @@ final class DeprecationAnalyzer {
        foreach($errors['messages'] as &$error) {

          // Overwrite message with processed text. Save category.
          list($message, $category) = $this->categorizeMessage($error['message'], $extension);
          [$message, $category] = $this->categorizeMessage($error['message'], $extension);
          $error['message'] = $message;
          $error['upgrade_status_category'] = $category;

+165 −0
Changes for src/ThemeFunctionDeprecationAnalyzer.php: 165 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\upgrade_status;

use Drupal\Core\Cache\NullBackend;
use Drupal\Core\DependencyInjection\Container;
use Drupal\Core\Extension\Extension;
use Drupal\Core\Theme\Registry;
use PhpParser\Error;
use PhpParser\Node;
use PhpParser\NodeFinder;
use PhpParser\ParserFactory;
use PhpParser\Node\Expr\ArrayDimFetch;
use PhpParser\Node\Expr\ArrayItem;
use PhpParser\Node\Expr\Assign;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\Function_;

/**
 * A theme function deprecation analyzer.
 */
final class ThemeFunctionDeprecationAnalyzer {

  /**
   * The service container.
   *
   * @var \Drupal\Core\DependencyInjection\Container
   */
  private $container;

  /**
   * Constructs a new theme function deprecation analyzer.
   *
   * @param \Drupal\Core\DependencyInjection\Container $this->container
   *   The service container.
   */
  public function __construct(Container $container) {
    $this->container = $container;
  }

  /**
   * Analyzes theme functions in an extension.
   *
   * @param \Drupal\Core\Extension\Extension $extension
   *   The extension to be analyzed.
   *
   * @return \Drupal\upgrade_status\DeprecationMessage[]
   */
  public function analyze(Extension $extension): array {
    $deprecation_messages = [];
    // Analyze hook_theme and hook_theme_registry_alter functions.
    $deprecation_messages = array_merge($deprecation_messages, $this->analyzeFunction($extension->getName() . '_' . 'theme', $extension));
    $deprecation_messages = array_merge($deprecation_messages, $this->analyzeFunction($extension->getName() . '_' . 'theme_registry_alter', $extension));

    // If a theme is being analyzed, theme function overrides need to be
    // analyzed.
    if ($extension->getType() === 'theme') {
      // Create new instance of theme registry to ensure that we have the most
      // recent data without having to make changes to the production theme
      // registry.
      $theme_registry = new Registry($this->container->get('app.root'), new NullBackend('null'), $this->container->get('lock'), $this->container->get('module_handler'), $this->container->get('theme_handler'), $this->container->get('theme.initialization'), $extension->getName());
      $theme_registry->setThemeManager($this->container->get('theme.manager'));
      $theme_hooks = $theme_registry->get();

      $theme_function_overrides = drupal_find_theme_functions($theme_hooks, [$extension->getName()]);
      foreach ($theme_function_overrides as $machine_name => $theme_function_override) {
        try {
          $function = new \ReflectionFunction($extension->getName() . '_' . $machine_name);
          $file = $function->getFileName();
          $line = $function->getStartLine();
          $deprecation_messages[$extension->getName() . '_' . $machine_name] = new DeprecationMessage(sprintf('The theme is overriding the "%s" theme function. Theme functions are deprecated. For more info, see https://www.drupal.org/node/2575445.', $machine_name), $file, $line);
        } catch (\ReflectionException $e) {
          // This should never happen because drupal_find_theme_functions()
          // ensures that the function exists.
        }
      }
    }

    return $deprecation_messages;
  }

  /**
   * Analyzes function for definition of theme functions.
   *
   * This doesn't recognize functions in all edge cases. For example, theme
   * functions could be generated dynamically in a number of different ways.
   * However, this will be useful in most use cases.
   *
   * @param $function
   *   The function to be analyzed.
   * @param \Drupal\Core\Extension\Extension $extension
   *   The extension that is being tested.
   *
   * @return \Drupal\upgrade_status\DeprecationMessage[]
   */
  private function analyzeFunction(string $function, Extension $extension): array {
    $deprecation_messages = [];

    try {
      $function_reflection = new \ReflectionFunction($function);
    } catch (\ReflectionException $e) {
      // Not all extensions implement theme hooks.
      return [];
    }

    $parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
    try {
      $ast = $parser->parse(file_get_contents($function_reflection->getFileName()));
    } catch (Error $error) {
      // The function cannot be evaluated because of a syntax error.
      $deprecation_messages[] = new DeprecationMessage(sprintf('Parse error while processing the %s hook implementation.', $theme_function), $function_reflection->getFileName(), $node->getStartLine());
    }

    if (!is_iterable($ast)) {
      return [];
    }
    $finder = new NodeFinder();
    // Find the node for the function that is being analyzed.
    $function_node = $finder->findFirst($ast, function (Node $node) use ($function) {
      return ($node instanceof Function_ && isset($node->name) && $node->name->name === $function);
    });

    if (!$function_node) {
      // This should never happen because the file has been loaded based on the
      // existence of the function.
      return [];
    }

    // Find theme functions that have been defined using the array syntax.
    // @code
    // function hook_theme() {
    //   return [
    //     'theme_hook' => ['function' => theme_function'],
    //   ];
    // }
    // @endcode
    $theme_function_nodes = $finder->find([$function_node], function(Node $node) {
      return ($node instanceof ArrayItem && $node->key instanceof String_ && $node->key->value === 'function');
    });
    foreach ($theme_function_nodes as $node) {
      $theme_function = $node->value instanceof String_ ? sprintf('"%s"', $node->value->value) : 'an unknown';
      $deprecation_messages[] = new DeprecationMessage(sprintf('The %s is defining %s theme function. Theme functions are deprecated. For more info, see https://www.drupal.org/node/2575445.', $extension->getType(), $theme_function), $function_reflection->getFileName(), $node->getStartLine());
    }

    // Find theme functions that are being added to an existing array using 
    // the array square bracket syntax.
    // @code
    // function hook_theme_registry_alter(&$theme_registry) {
    //   $theme_registry['theme_hook']['function'] = 'another_theme_function';
    // }
    // @endcode
    $theme_function_dim_nodes = $finder->find([$function_node], function(Node $node) {
      return $node instanceof Assign && $node->var instanceof ArrayDimFetch && $node->var->dim instanceof String_ && $node->var->dim->value === 'function';
    });
    foreach ($theme_function_dim_nodes as $node) {
      $theme_function = $node->expr instanceof String_ ? sprintf('"%s"', $node->expr->value) : 'an unknown';
      $deprecation_messages[] = new DeprecationMessage(sprintf('The %s is defining %s theme function. Theme functions are deprecated. For more info, see https://www.drupal.org/node/2575445.', $extension->getType(), $theme_function), $function_reflection->getFileName(), $node->getStartLine());
    }

    return $deprecation_messages;
  }

}
+7 −0
Changes for tests/modules/upgrade_status_test_theme_functions/upgrade_status_test_theme_functions.info.yml: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
name: 'Upgrade Status Test module with theme functions'
type: module
description: 'Test module for testing theme function deprecation messages'
core: 8.x
core_version_requirement: ^8 || ^9
package: Testing
version: VERSION
+29 −0
Changes for tests/modules/upgrade_status_test_theme_functions/upgrade_status_test_theme_functions.module: 29 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

/**
 * Implements hook_theme().
 */
function upgrade_status_test_theme_functions_theme() {
  return [
    'upgrade_status_test_theme_function' => [
      'function' => 'upgrade_status_test_theme_function'
    ],
    'upgrade_status_test_theme_function_another_function' => [],
    'upgrade_status_test_theme_function_theme_function_override' => [],
  ];
}

/**
 * Implements hook_theme_registry_alter().
 */
function upgrade_status_test_theme_functions_theme_registry_alter(&$theme_registry) {
  $theme_registry['upgrade_status_test_theme_function_another_function']['function'] = 'upgrade_status_test_theme_function';
  $theme_registry['upgrade_status_test_theme_function_non_existing_function']['function'] = sprintf('upgrade_status_test_theme_function');
}

/**
 * Theme function used for testing.
 */
function upgrade_status_test_theme_function() {
  return 'kitten';
}
Loading