.phan/plugins/UnusedSuppressionPlugin.php

Summary

Maintainability
B
5 hrs
Test Coverage
<?php

declare(strict_types=1);

use ast\Node;
use Phan\CodeBase;
use Phan\Config;
use Phan\Language\Context;
use Phan\Language\Element\AddressableElement;
use Phan\Language\Element\Clazz;
use Phan\Language\Element\Func;
use Phan\Language\Element\Method;
use Phan\Language\Element\Property;
use Phan\Plugin\ConfigPluginSet;
use Phan\PluginV3;
use Phan\PluginV3\AnalyzeClassCapability;
use Phan\PluginV3\AnalyzeFunctionCapability;
use Phan\PluginV3\AnalyzeMethodCapability;
use Phan\PluginV3\AnalyzePropertyCapability;
use Phan\PluginV3\BeforeAnalyzeFileCapability;
use Phan\PluginV3\FinalizeProcessCapability;
use Phan\PluginV3\SuppressionCapability;

/**
 * Check for unused (at)suppress annotations.
 *
 * NOTE! This plugin only produces correct results when Phan
 *       is run on a single processor (via the `-j1` flag).
 */
class UnusedSuppressionPlugin extends PluginV3 implements
    BeforeAnalyzeFileCapability,
    AnalyzeClassCapability,
    AnalyzeFunctionCapability,
    AnalyzeMethodCapability,
    AnalyzePropertyCapability,
    FinalizeProcessCapability
{

    /**
     * @var AddressableElement[] - Analysis is postponed until finalizeProcess.
     * Issues may have been emitted after `$this->analyze*()` were called,
     * which is why those methods postpone the check until analysis is finished.
     *
     * Also, looping over all elements again would be slow.
     *
     * These are currently unique, even when quick_mode is false.
     */
    private $elements_for_postponed_analysis = [];

    /**
     * @var string[] a list of files where checks for unused suppressions was postponed
     * (Because of non-quick mode, we may emit issues in a file after analysis has run on that file)
     */
    private $files_for_postponed_analysis = [];

    /**
     * @var array<string,array<string,array<string,array<int,int>>>> stores the suppressions for active plugins
     *   maps plugin class to
     *     file name to
     *       issue type to
     *         unique list of line numbers of suppressions
     */
    private $plugin_active_suppression_list;

    /**
     * @param CodeBase $code_base
     * The code base in which the element exists
     *
     * @param AddressableElement $element
     * Any element such as function, method, class
     * (which has an FQSEN)
     */
    private static function analyzeAddressableElement(
        CodeBase $code_base,
        AddressableElement $element
    ): void {
        // Get the set of suppressed issues on the element
        $suppress_issue_list =
            $element->getSuppressIssueList();

        if (\array_key_exists('UnusedSuppression', $suppress_issue_list)) {
            // The element's doc comment is suppressing everything emitted by this plugin.
            return;
        }

        // Check to see if any are unused
        foreach ($suppress_issue_list as $issue_type => $use_count) {
            if (0 !== $use_count) {
                continue;
            }
            if (in_array($issue_type, self::getUnusedSuppressionIgnoreList(), true)) {
                continue;
            }
            self::emitIssue(
                $code_base,
                $element->getContext(),
                'UnusedSuppression',
                "Element {FUNCTIONLIKE} suppresses issue {ISSUETYPE} but does not use it",
                [(string)$element->getFQSEN(), $issue_type]
            );
        }
    }

    private function postponeAnalysisOfElement(AddressableElement $element): void
    {
        if (count($element->getSuppressIssueList()) === 0) {
            // There are no suppressions, so there's no reason to check this
            return;
        }
        $this->elements_for_postponed_analysis[] = $element;
    }

    /**
     * @param CodeBase $code_base @unused-param
     * The code base in which the class exists
     *
     * @param Clazz $class
     * A class being analyzed
     * @override
     */
    public function analyzeClass(
        CodeBase $code_base,
        Clazz $class
    ): void {
        $this->postponeAnalysisOfElement($class);
    }

    /**
     * @param CodeBase $code_base @unused-param
     * The code base in which the method exists
     *
     * @param Method $method
     * A method being analyzed
     * @override
     */
    public function analyzeMethod(
        CodeBase $code_base,
        Method $method
    ): void {

        // Ignore methods inherited by subclasses
        if ($method->getFQSEN() !== $method->getRealDefiningFQSEN()) {
            return;
        }

        $this->postponeAnalysisOfElement($method);
    }

    /**
     * @param CodeBase $code_base @unused-param
     * The code base in which the function exists
     *
     * @param Func $function
     * A function being analyzed
     * @override
     */
    public function analyzeFunction(
        CodeBase $code_base,
        Func $function
    ): void {
        $this->postponeAnalysisOfElement($function);
    }

    /**
     * @param CodeBase $code_base @unused-param
     * The code base in which the property exists
     *
     * @param Property $property
     * A property being analyzed
     * @override
     */
    public function analyzeProperty(
        CodeBase $code_base,
        Property $property
    ): void {
        if ($property->getFQSEN() !== $property->getRealDefiningFQSEN()) {
            return;
        }
        $this->elements_for_postponed_analysis[] = $property;
    }

    /**
     * NOTE! This plugin only produces correct results when Phan
     *       is run on a single processor (via the `-j1` flag).
     *       Putting this hook in finalizeProcess() just minimizes the incorrect result counts.
     * @override
     */
    public function finalizeProcess(CodeBase $code_base): void
    {
        foreach ($this->elements_for_postponed_analysis as $element) {
            self::analyzeAddressableElement($code_base, $element);
        }
        $this->analyzePluginSuppressions($code_base);
    }

    private function analyzePluginSuppressions(CodeBase $code_base): void
    {
        $suppression_plugin_set = ConfigPluginSet::instance()->getSuppressionPluginSet();
        if (count($suppression_plugin_set) === 0) {
            return;
        }

        foreach ($this->files_for_postponed_analysis as $file_path) {
            foreach ($suppression_plugin_set as $plugin) {
                $this->analyzePluginSuppressionsForFile($code_base, $plugin, $file_path);
            }
        }
    }

    /**
     * @return list<string>
     */
    private static function getUnusedSuppressionIgnoreList(): array
    {
        return Config::getValue('plugin_config')['unused_suppression_ignore_list'] ?? [];
    }

    private static function getReportOnlyWhitelisted(): bool
    {
        return Config::getValue('plugin_config')['unused_suppression_whitelisted_only'] ?? false;
    }

    private static function shouldReportUnusedSuppression(string $issue_type): bool
    {
        $ignore_list = self::getUnusedSuppressionIgnoreList();
        $only_whitelisted = self::getReportOnlyWhitelisted();
        $issue_whitelist = Config::getValue('whitelist_issue_types') ?? [];

        return !in_array($issue_type, $ignore_list, true) &&
            (!$only_whitelisted || in_array($issue_type, $issue_whitelist, true));
    }

    private function analyzePluginSuppressionsForFile(CodeBase $code_base, SuppressionCapability $plugin, string $relative_file_path): void
    {
        $absolute_file_path = Config::projectPath($relative_file_path);
        $plugin_class = \get_class($plugin);
        $name_pos = \strrpos($plugin_class, '\\');
        if ($name_pos !== false) {
            $plugin_name = \substr($plugin_class, $name_pos + 1);
        } else {
            $plugin_name = $plugin_class;
        }
        $plugin_suppressions = $plugin->getIssueSuppressionList($code_base, $absolute_file_path);
        $plugin_successful_suppressions = $this->plugin_active_suppression_list[$plugin_class][$absolute_file_path] ?? null;

        foreach ($plugin_suppressions as $issue_type => $line_list) {
            foreach ($line_list as $lineno => $lineno_of_comment) {
                if (isset($plugin_successful_suppressions[$issue_type][$lineno])) {
                    continue;
                }
                // TODO: finish letting plugins suppress UnusedSuppression on other plugins
                $issue_kind = 'UnusedPluginSuppression';
                $message = 'Plugin {STRING_LITERAL} suppresses issue {ISSUETYPE} on this line but this suppression is unused or suppressed elsewhere';
                if ($lineno === 0) {
                    $issue_kind = 'UnusedPluginFileSuppression';
                    $message = 'Plugin {STRING_LITERAL} suppresses issue {ISSUETYPE} in this file but this suppression is unused or suppressed elsewhere';
                }
                if (isset($plugin_suppressions['UnusedSuppression'][$lineno_of_comment])) {
                    continue;
                }
                if (isset($plugin_suppressions[$issue_kind][$lineno_of_comment])) {
                    continue;
                }
                if (!self::shouldReportUnusedSuppression($issue_type)) {
                    continue;
                }
                self::emitIssue(
                    $code_base,
                    (new Context())->withFile($relative_file_path)->withLineNumberStart($lineno_of_comment),
                    $issue_kind,
                    $message,
                    [$plugin_name, $issue_type]
                );
            }
        }
        return;
    }

    /**
     * @unused-param $code_base
     * @unused-param $file_contents
     * @unused-param $node
     */
    public function beforeAnalyzeFile(
        CodeBase $code_base,
        Context $context,
        string $file_contents,
        Node $node
    ): void {
        $file = $context->getFile();
        $this->files_for_postponed_analysis[$file] = $file;
    }

    /**
     * Record the fact that $plugin caused suppressions in $file_path for issue $issue_type due to an annotation around $line
     * @internal
     */
    public function recordPluginSuppression(
        SuppressionCapability $plugin,
        string $file_path,
        string $issue_type,
        int $line
    ): void {
        $file_name = Config::projectPath($file_path);
        $plugin_class = \get_class($plugin);
        $this->plugin_active_suppression_list[$plugin_class][$file_name][$issue_type][$line] = $line;
    }
}

// Every plugin needs to return an instance of itself at the
// end of the file in which it's defined.
return new UnusedSuppressionPlugin();