src/Phan/Plugin/Internal/BaselineSavingPlugin.php

Summary

Maintainability
A
2 hrs
Test Coverage
<?php

declare(strict_types=1);

namespace Phan\Plugin\Internal;

use Phan\CLI;
use Phan\CodeBase;
use Phan\Config;
use Phan\IssueInstance;
use Phan\Language\FileRef;
use Phan\Phan;
use Phan\PluginV3;
use Phan\PluginV3\FinalizeProcessCapability;
use Phan\PluginV3\SubscribeEmitIssueCapability;

use function in_array;

/**
 * This plugin generates a baseline from the issues that weren't suppressed by other plugins or config settings.
 *
 * NOTE: This is automatically loaded by phan. Do not include it in a config.
 */
final class BaselineSavingPlugin extends PluginV3 implements
    SubscribeEmitIssueCapability,
    FinalizeProcessCapability
{
    /** @var string the path of the file to save to. */
    private $baseline_path;

    /**
     * Maps project file paths to a set of issue types emitted in that file.
     * @var array<string,array<string, true>>
     */
    private $suppressions_by_file = [];

    /**
     * Maps issue name to a set of hashes of unique issues.
     *
     * This is used to determine the count of issues to show in the baseline being saved.
     *
     * This is approximate because BufferingCollector deduplicates issues slightly differently.
     *
     * @var array<string,array<string,true>>
     */
    private $suppressions_by_type = [];

    public function __construct(string $baseline_path)
    {
        $this->baseline_path = $baseline_path;
    }

    /**
     * This method is called before Phan emits an issue not suppressed elsewhere.
     *
     * @param IssueInstance $issue_instance
     *
     * @return false this plugin does not suppress anything - it just records issues to generate a file that can be used by BaselineReadingPlugin in subsequent runs.
     */
    public function onEmitIssue(
        IssueInstance $issue_instance
    ): bool {
        $file_path = $issue_instance->getFile();
        $file_path = FileRef::getProjectRelativePathForPath($file_path);
        if (Phan::isExcludedAnalysisFile($file_path)) {
            return false;
        }
        // Generate compatible baselines on Windows
        $file_path = \str_replace(\DIRECTORY_SEPARATOR, '/', $file_path);

        // Would prefer to use formatSortableKey, but this doesn't provide the IssueInstance, and plugins have issues that can't be fetched with Issue::fromType.
        $hash = \sha1($issue_instance->__toString());
        $issue_type = $issue_instance->getIssue()->getType();
        $this->suppressions_by_file[$file_path][$issue_type] = true;
        $this->suppressions_by_type[$issue_type][$hash] = true;
        return false;
    }

    /**
     * @unused-param $code_base
     */
    public function finalizeProcess(CodeBase $code_base): void
    {
        CLI::printToStderr("Saving a new issue baseline to '$this->baseline_path'\n" .
            "Subsequent Phan runs can read from this file with --load-baseline='$this->baseline_path' to ignore pre-existing issues.\n");
        $contents = $this->generateAllBaselineContents();
        \file_put_contents($this->baseline_path, $contents);
    }

    private function generateAllBaselineContents(): string
    {
        $contents = <<<'EOT'
<?php
/**
 * This is an automatically generated baseline for Phan issues.
 * When Phan is invoked with --load-baseline=path/to/baseline.php,
 * The pre-existing issues listed in this file won't be emitted.
 *
 * This file can be updated by invoking Phan with --save-baseline=path/to/baseline.php
 * (can be combined with --load-baseline)
 */
return [

EOT;
        $summary_type = Config::getValue('baseline_summary_type');
        $contents .= $this->generateSuppressIssueSummary($summary_type);
        $contents .= $this->generateSuppressFileEntries();
        $contents .= "];\n";
        return $contents;
    }

    private static function roundSuppressCount(int $count): int
    {
        if ($count <= 10) {
            return $count;
        }
        if ($count <= 100) {
            return $count - $count % 5;
        }
        return $count - $count % 10;
    }

    private static function getSuppressCountLabel(int $count): string
    {
        if ($count < 10) {
            return (string)$count;
        }
        // Round counts over 100 down to a multiple of 10, etc.
        return \sprintf('%d+', self::roundSuppressCount($count));
    }

    /**
     * Generates a summary of the suppressed issues, sorted by approximate counts.
     *
     * This is useful for checking if issues that you don't want in your project
     * have been added into a large baseline.
     */
    private function generateSuppressIssueSummary(string $baseline_summary_type): string
    {
        if ($baseline_summary_type === 'none') {
            return '';
        }
        if (!in_array($baseline_summary_type, ['ordered_by_type', 'ordered_by_count'], true)) {
            CLI::printWarningToStderr("Unknown baseline_summary_type '$baseline_summary_type'. Supported values: 'ordered_by_type', 'ordered_by_count', 'none'");
        }
        $entries = [];
        foreach ($this->suppressions_by_type as $issue_type => $hashes) {
            $count = \count($hashes);
            if ($baseline_summary_type === 'ordered_by_type') {
                $key = $issue_type;
            } else {
                $key = -self::roundSuppressCount($count);
            }
            $count_name = self::getSuppressCountLabel($count);
            $entries[] = [
                $key,
                $issue_type,
                \sprintf("    // %s : %s %s\n", $issue_type, $count_name, $count !== 1 ? "occurrences" : "occurrence"),
            ];
        }
        if (!$entries) {
            return "    // This baseline has no suppressions\n";
        }
        // Sort by most common issues first, breaking ties by the name of the issue.
        \sort($entries);
        $result = "    // # Issue statistics:\n";
        foreach ($entries as [2 => $entry_text]) {
            $result .= $entry_text;
        }
        $result .= "\n";
        return $result;
    }

    /**
     * Generates the per-file suppressions of the baseline.
     *
     * This is useful for checking if issues that you don't want in your project
     * have been added into a large baseline.
     */
    private function generateSuppressFileEntries(): string
    {
        $result = '';
        $result .= "    // Currently, file_suppressions and directory_suppressions are the only supported suppressions\n";
        $result .= "    'file_suppressions' => [\n";
        \uksort($this->suppressions_by_file, 'strcmp');
        foreach ($this->suppressions_by_file as $file_name => $type_set) {
            $types = \array_map('strval', \array_keys($type_set));
            \usort($types, 'strcmp');
            $result .= "        '$file_name' => [" . \implode(', ', \array_map(static function (string $type): string {
                    return "'" . $type . "'";
            }, $types)) . "],\n";
        }
        $result .= "    ],\n";
        $result .= "    // 'directory_suppressions' => ['src/directory_name' => ['PhanIssueName1', 'PhanIssueName2']] can be manually added if needed.\n";
        $result .= "    // (directory_suppressions will currently be ignored by subsequent calls to --save-baseline, but may be preserved in future Phan releases)\n";
        return $result;
    }
}