api-8.x-1.x-dev/src/Parser.php
src/Parser.php
<?php
namespace Drupal\api;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\File\FileSystem;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Queue\QueueFactory;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\api\Entity\Branch;
use Drupal\api\Entity\DocBlock;
use Drupal\api\Entity\ExternalBranch;
use Drupal\api\Entity\PhpBranch;
use Drupal\api\Interfaces\BranchInterface;
use Drupal\api\Traits\ReParseTrait;
use DrupalPrettyPrinter\DrupalPrettyPrinter;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use League\CommonMark\CommonMarkConverter;
use PhpParser\Comment\Doc as CommentDoc;
use PhpParser\Error as ParserError;
use PhpParser\Modifiers;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\ClassLike as NodeClassLike;
use PhpParser\Node\Stmt\Function_ as NodeFunction;
use PhpParser\ParserFactory;
use PhpParser\PrettyPrinter\Standard as StandardPrettyPrinter;
use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use Symfony\Component\Yaml\Dumper as YamlDumper;
use Symfony\Component\Yaml\Parser as YamlParser;
use Symfony\Component\Yaml\Yaml;
/**
* Parser service containing utilities to parse the code.
*
* The main goal from the parser will be to scan the files, produce docblocks
* and store that information into queues. This way we don't need to worry about
* any abstractions of objects, entities, dependencies... and therefore making
* the logic a bit simpler.
*
* @package Drupal\api
*/
class Parser {
use StringTranslationTrait;
use ReParseTrait;
/**
* Name of the queue to parse branches.
*
* @var string
*/
const QUEUE_PARSE = 'api_parse_queue';
/**
* Regular expression for starting inline \@tags.
*
* @var string
*/
const RE_TAG_START = '(?<!\\\)@';
/**
* Regular expression for matching file names with one or more extensions.
*
* @var string
*/
const RE_FILENAME = '([a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)+)';
/**
* Regular expression for matching PHP functions and methods in text.
*
* These are patterns like ClassName::methodName(), or just function_name().
* Possibly with namespaces. Doesn't include the ().
*
* @var string
*/
const RE_FUNCTION_IN_TEXT = '\\\\*[a-zA-Z_\x7f-\xff][\\\\a-zA-Z0-9_\x7f-\xff:]*';
/**
* Regular expression for matching characters interior to function names.
*
* @var string
*/
const RE_FUNCTION_CHARACTERS = '[a-zA-Z0-9_\x7f-\xff]+';
/**
* Regular expression to match PHP function names, without delimiters.
*
* Former (deprecated) DRUPAL_PHP_FUNCTION_PATTERN.
*
* @var string
*/
const PHP_FUNCTION_PATTERN = '[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*';
/**
* Regular expression for word boundary start for API linking.
*
* @var string
*/
const RE_WORD_BOUNDARY_START = '(?<=^|[\s\(@\>|])';
/**
* Regular expression for word boundary end for API linking.
*
* @var string
*/
const RE_WORD_BOUNDARY_END = '(?=$|[\s.,:;?!)\<\[\|])';
/**
* DateFormatterInterface definition.
*
* @var \Drupal\Core\Datetime\DateFormatterInterface
*/
protected $dateFormatter;
/**
* ModuleHandlerInterface definition.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* ConfigFactoryInterface definition.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* Utilities definition.
*
* @var \Drupal\api\Utilities
*/
protected $utilities;
/**
* The queue object.
*
* @var \Drupal\Core\Queue\QueueFactory
*/
protected $queueFactory;
/**
* The time service.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected $time;
/**
* Http client.
*
* @var \Guzzle\Client
*/
protected $httpClient;
/**
* Parsing queue.
*
* @var \Drupal\Core\Queue\QueueInterface
*/
protected $parseQueue;
/**
* File system service.
*
* @var \Drupal\Core\File\FileSystem
*/
protected $fileSystem;
/**
* PHP Parser factory object.
*
* @var \PhpParser\ParserFactory
*/
protected $phpParser;
/**
* Logger instance for the api module.
*
* @var \Drupal\Core\Logger\LoggerChannelInterface
*/
protected $logger;
/**
* Logger service.
*
* @var \Drupal\Core\Logger\LoggerChannelFactoryInterface
*/
protected $loggerService;
/**
* Parser constructor.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* Configuration service.
* @param \Drupal\Core\Datetime\DateFormatterInterface $date_formatter
* Date formatter service.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* Module handler service.
* @param \Drupal\api\Utilities $utilities
* Utilities service.
* @param \Drupal\Core\Queue\QueueFactory $queue_factory
* Queue factory service to get new/existing queues for use.
* @param \Drupal\Component\Datetime\TimeInterface $time
* Time service.
* @param \GuzzleHttp\Client $http_client
* Http client.
* @param \Drupal\Core\File\FileSystem $file_system
* File system service.
* @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_service
* Logger service.
*/
public function __construct(
ConfigFactoryInterface $config_factory,
DateFormatterInterface $date_formatter,
ModuleHandlerInterface $module_handler,
Utilities $utilities,
QueueFactory $queue_factory,
TimeInterface $time,
Client $http_client,
FileSystem $file_system,
LoggerChannelFactoryInterface $logger_service,
) {
$this->configFactory = $config_factory;
$this->dateFormatter = $date_formatter;
$this->moduleHandler = $module_handler;
$this->utilities = $utilities;
$this->queueFactory = $queue_factory;
$this->time = $time;
$this->httpClient = $http_client;
$this->fileSystem = $file_system;
$this->loggerService = $logger_service;
$this->logger = $this->loggerService->get('api');
$this->phpParser = (new ParserFactory())->createForNewestSupportedVersion();
$this->parseQueue = $this->queueFactory->get(self::QUEUE_PARSE);
$this->parseQueue->createQueue();
}
/**
* Triggers parsing on all the entities that can be parsed.
*
* @return array
* Array with the different results for the different entities parsed.
*/
public function parseAll() {
$limit = (int) $this->configFactory->get('api.settings')->get('branches_per_cron');
return [
'branch' => $this->parseBranches($limit),
'php_branch' => $this->parsePhpBranches(),
'external_branch' => $this->parseExternalBranches(),
];
}
/**
* Parse a single file in full.
*
* @param string $path
* File to parse.
* @param int $branch_id
* Id of the branch.
*
* @return bool
* Whether the file was parsed or not.
*/
public function parseSingleFile(string $path, int $branch_id) {
$branch = Branch::load($branch_id);
if (empty($branch)) {
return FALSE;
}
$branch_path = $branch->getDirectories();
$file_path = str_replace($branch_path, '', $path);
if ($file_path === $path) {
return FALSE;
}
$finder = new Finder();
try {
// Set branch path so relative paths are properly calculated.
$files = $finder
->in($branch_path)
->path(trim(dirname($file_path), '/'))
->files();
}
catch (DirectoryNotFoundException $e) {
return FALSE;
}
$parse_functions = $this->parseFunctions();
if ($files->hasResults()) {
foreach ($files as $file) {
if ($file->getPathname() === $path) {
/** @var \Symfony\Component\Finder\SplFileInfo $file */
$extension = $file->getExtension();
if (!empty($parse_functions[$extension])) {
$parseFunction = $parse_functions[$extension];
$baseFileDocblock = $this->parseFile($file);
if (!empty($baseFileDocblock)) {
return DocBlock::createOrUpdate($this->$parseFunction($baseFileDocblock, $branch) ?? [], $branch);
}
}
}
}
}
return FALSE;
}
/**
* Triggers the parsing for branches.
*
* @param int $limit
* Max number of branches to parse. 0 to parse all (it might time out).
*
* @return array
* Results from the parsing.
*/
public function parseBranches(int $limit = 0) {
$limit = ($limit <= 0) ? 1000 : $limit;
$results = [];
$parsed_count = 0;
$limit_reached = FALSE;
$branches = Branch::loadMultiple() ?? [];
foreach ($branches as $branch) {
/** @var \Drupal\api\Entity\Branch $branch */
$parsed = FALSE;
$docblock_counter = 0;
$needs_parsing = $this->needsParsing($branch);
if ($needs_parsing && $limit_reached == FALSE) {
// Automatically "git pull" before we reparse.
$this->utilities->pullBranch($branch);
$parse_functions = $this->parseFunctions();
$files_to_scan = $this->filesToScan($branch);
if (!is_null($files_to_scan) && $files_to_scan->hasResults()) {
foreach ($files_to_scan as $file) {
/** @var \Symfony\Component\Finder\SplFileInfo $file */
$extension = $file->getExtension();
if (!empty($parse_functions[$extension])) {
$parseFunction = $parse_functions[$extension];
$baseFileDocblock = $this->parseFile($file, $branch->getExcludeDrupalismRegexp(TRUE));
if (!empty($baseFileDocblock)) {
$docblock_info = [
'branch_id' => $branch->id(),
'branch_type' => $branch->getEntityTypeId(),
'action' => 'parse',
'data' => $this->$parseFunction($baseFileDocblock, $branch) ?? [],
];
$docblock_counter++;
$this->parseQueue->createItem($docblock_info);
}
}
}
}
$parsed = TRUE;
$branch
->setQueued($this->time->getCurrentTime())
->save();
$parsed_count++;
$limit_reached = ($parsed_count >= $limit);
// In the D7 version, a lot was calculated on saving (see "api_shutdown"
// function). Here, we will just queue the next actions after all the
// files have been set for parsing.
$counts_info = [
'branch_id' => $branch->id(),
'branch_type' => $branch->getEntityTypeId(),
'action' => 'class_relations',
];
$this->parseQueue->createItem($counts_info);
$hook_info = [
'branch_id' => $branch->id(),
'branch_type' => $branch->getEntityTypeId(),
'action' => 'hook_pattern',
];
$this->parseQueue->createItem($hook_info);
}
$label = $branch->getProject() ?
$branch->getProject()->label() . ' - ' . $branch->label() :
$branch->label();
$results[$branch->id()] = [
'label' => $label,
'docblock_count' => $docblock_counter,
'parsed' => $parsed,
'needs_parsing' => $needs_parsing,
'limit_exceeded' => (!$parsed && $limit_reached),
];
}
return $results;
}
/**
* Triggers the parsing for PHP branches.
*
* @return array
* Results from the parsing.
*/
public function parsePhpBranches() {
$results = [];
$branches = PhpBranch::loadMultiple() ?? [];
foreach ($branches as $branch) {
/** @var \Drupal\api\Entity\PhpBranch $branch */
$parsed = FALSE;
$docblock_counter = 0;
$needs_parsing = $this->needsParsing($branch);
if ($needs_parsing) {
try {
$response = $this->httpClient->get($branch->getFunctionList(), [
'timeout' => 60,
]);
}
catch (ClientException $e) {
$response = NULL;
}
if ($response?->getStatusCode() == 200) {
$data = Json::decode($response->getBody()->getContents()) ?? [];
foreach ($data as $name => $item) {
$docblock_info = [
'branch_id' => $branch->id(),
'branch_type' => $branch->getEntityTypeId(),
'action' => 'parse',
'data' => [
'object_type' => 'function',
'object_name' => $name,
'documentation' => $item['prototype'] . "\n " . $item['purpose'],
'member_name' => '',
],
];
$docblock_counter++;
$this->parseQueue->createItem($docblock_info);
}
$parsed = TRUE;
$branch
->setQueued($this->time->getCurrentTime())
->save();
}
}
$results[$branch->id()] = [
'label' => $branch->label(),
'docblock_count' => $docblock_counter,
'parsed' => $parsed,
'needs_parsing' => $needs_parsing,
];
}
return $results;
}
/**
* Triggers the parsing for External branches.
*
* @return array
* Results from the parsing.
*/
public function parseExternalBranches() {
$results = [];
$branches = ExternalBranch::loadMultiple() ?? [];
foreach ($branches as $branch) {
/** @var \Drupal\api\Entity\ExternalBranch $branch */
$parsed = FALSE;
$docblock_counter = 0;
$needs_parsing = $this->needsParsing($branch);
if ($needs_parsing) {
$limit = $branch->getItemsPerPage() ?? 0;
$timeout = $branch->getTimeout() ?? 0;
if ($limit <= 0) {
$limit = 2000;
}
if ($timeout <= 0) {
$timeout = 30;
}
$reference_url = $branch->getFunctionList();
$reference_url = $reference_url .
((strpos($reference_url, '?') > 0) ? '&' : '?') .
'limit=' . $limit . '&page=';
$done = [];
$page = 0;
$found = TRUE;
// Continue in this loop as long as the last response was successful and
// contained new data, but as a failsafe stop after 1000 pages.
while ($found && $page < 1000) {
$found = FALSE;
try {
$response = $this->httpClient->get($reference_url . $page, [
'timeout' => $timeout,
]);
}
catch (\Throwable $e) {
// End-point not reachable, or forbidden. Loop will just end.
$response = FALSE;
}
$page++;
if ($response && $response->getStatusCode() == 200) {
$data = Json::decode($response->getBody()->getContents()) ?? [];
foreach ($data as $item) {
// Only save new items.
$key = $item['url'] . '..' . $item['object_type'] . '..' . $item['namespaced_name'];
if (!isset($done[$key])) {
$found = TRUE;
$done[$key] = TRUE;
$docblock_info = [
'branch_id' => $branch->id(),
'branch_type' => $branch->getEntityTypeId(),
'action' => 'parse',
'data' => $item,
];
$docblock_counter++;
$this->parseQueue->createItem($docblock_info);
}
}
}
}
$parsed = TRUE;
$branch
->setQueued($this->time->getCurrentTime())
->save();
}
$results[$branch->id()] = [
'label' => $branch->label(),
'docblock_count' => $docblock_counter,
'parsed' => $parsed,
'needs_parsing' => $needs_parsing,
];
}
return $results;
}
/**
* Returns whether a branch needs parsing or not.
*
* @param \Drupal\api\Interfaces\PhpBranchInterface|\Drupal\api\Interfaces\ExternalBranchInterface $branch
* Branch to check.
*
* @return bool
* Whether the given branch needs parsing or not.
*/
public function needsParsing($branch) {
$key = static::getStateKey($branch);
if (\Drupal::state()->get($key)) {
\Drupal::state()->delete($key);
return TRUE;
}
return (
empty($branch->getQueued()) ||
(($branch->getQueued() + $branch->getUpdateFrequency()) < $this->time->getCurrentTime())
);
}
/**
* Returns the files that can be scanned based on the branch configuration.
*
* @param \Drupal\api\Interfaces\BranchInterface $branch
* Branch object containing path information.
*
* @return \Symfony\Component\Finder\Finder|null
* Finder object which can be used to loop through files.
*/
public function filesToScan(BranchInterface $branch) {
$paths = $branch->getDirectories(TRUE);
$excluded_paths = $branch->getExcludedDirectories(TRUE);
$excluded_regex = $branch->getExcludeFilesRegexp(TRUE);
$finder = new Finder();
try {
$finder->in($paths);
}
catch (DirectoryNotFoundException $e) {
$this->logger->error('Folder(s) @folder not found', [
'@folder' => implode(', ', $paths),
]);
return NULL;
}
// Exclude param MUST be relative as per Finder documentation:
// https://symfony.com/doc/current/components/finder.html#location
// As both paths and excluded could be multiple value we try the excluded
// paths against the included paths and try to get the relative address.
// Github issue: https://github.com/symfony/symfony/issues/34894
foreach ($excluded_paths as $excluded_path) {
foreach ($paths as $path) {
if (str_contains($excluded_path, $path)) {
$relative_path = ltrim(str_replace($path, '', $excluded_path), '/\\');
$finder->exclude($relative_path);
}
}
}
$finder->notPath($excluded_regex);
return $finder->files();
}
/**
* Reads in a file and returns a base object to use in parsing functions.
*
* @param \Symfony\Component\Finder\SplFileInfo $file
* File to parse.
* @param array $drupal_regexps
* Regular expressions to exclude Drupalisms.
*
* @return array
* Base docblock structure for the file.
*/
public function parseFile(SplFileInfo $file, array $drupal_regexps = []) {
// See if this is a Drupal file or a Drupal-excluded file.
$is_drupal = TRUE;
foreach ($drupal_regexps as $regexp) {
if (preg_match($regexp, $file->getPathname())) {
$is_drupal = FALSE;
break;
}
}
$basename = $this->fileSystem->basename($file->getPathname());
$source = $file->getContents();
return [
'object_name' => $file->getFilename(),
'object_type' => 'file',
'file_name' => $file->getRelativePathname(),
'title' => $basename,
'basename' => $basename,
'documentation' => '',
'references' => [],
'modified' => $file->getMTime(),
'source' => str_replace(
["\r\n", "\r"],
["\n", "\n"],
$source
),
'content' => '',
'class' => '',
'namespaced_name' => '',
'modifiers' => '',
'is_drupal' => $is_drupal,
'code' => '',
];
}
/**
* Returns the list of parsing functions for file extensions.
*
* @return array
* An associative array whose keys are file extensions, and whose values are
* the functions used to parse files with that extension.
*
* The function should parse the contents of the file, and return
* documentation objects.
*/
public function parseFunctions() {
$functions = [
'php' => 'parsePhp',
'module' => 'parsePhp',
'inc' => 'parsePhp',
'install' => 'parsePhp',
'engine' => 'parsePhp',
'theme' => 'parsePhp',
'profile' => 'parsePhp',
'test' => 'parsePhp',
'yml' => 'parseYaml',
'txt' => 'parseText',
'info' => 'parseText',
'css' => 'parseText',
'sh' => 'parseText',
'twig' => 'parseTwig',
'htm' => 'parseHtml',
'html' => 'parseHtml',
'md' => 'parseMarkdown',
];
// Let other modules add/edit parsing options.
$this->moduleHandler->alter('api_parse_functions', $functions);
return $functions;
}
/**
* Parses a Php file and returns its docblock information.
*
* @param array $docblock
* Information about the file to parse.
* @param \Drupal\api\Interfaces\BranchInterface $branch
* (Optional) Branch where this docblock is.
*
* @return array
* Docblock array containing the different elements within the file.
*/
public function parsePhp(array $docblock, ?BranchInterface $branch = NULL) {
$error_logged = FALSE;
$statements = FALSE;
try {
$statements = $this->phpParser->parse($docblock['source']);
}
catch (ParserError $e) {
$error_logged = TRUE;
$this->logger->error('File @name could not be parsed. Message: %msg',
[
'@name' => $docblock['file_name'],
'%msg' => $e->getMessage(),
]);
}
if ($statements && is_array($statements)) {
// Find all the references in the whole file. We will omit the ones that
// belong to particular functions etc. in the file.
$references = $this->findPhpReferences($statements, $docblock['is_drupal'], $docblock['file_name']);
// Make the first doc block be for the file as a whole.
$docblock['code'] = Formatter::formatStatements($statements, $docblock['is_drupal'], TRUE);
$docblocks = [$docblock];
// Set default documentation block array for items other than the file.
$default_block = [
'file_name' => $docblock['file_name'],
'class' => '',
'object_type' => '',
'documentation' => '',
'references' => [],
'see' => '',
'deprecated' => '',
'start_line' => 0,
'namespaced_name' => '',
'modifiers' => '',
'is_drupal' => $docblock['is_drupal'],
'code' => '',
];
$found_references = $this->buildPhpDocblocks($statements, $default_block, $docblocks);
// For the file, save only the references not found in parsing the
// statements within the file. This will only save references to function
// calls in the global area of the file, excluding those found in
// functions declared in the file. This doesn't apply to namespace and use
// references though -- these are found only in ::buildPhpDocblocks().
foreach ($found_references as $type => $list) {
// For namespaces and use aliases, merge these into the main references.
if ($type == 'namespace' || $type == 'use_alias') {
$refs = [$type => $list];
$references = $this->mergeReferences($references, $refs, $docblock['file_name']);
}
else {
// For other references, remove them.
foreach ($list as $name => $info) {
unset($references[$type][$name]);
}
}
}
$docblocks[0]['references'] = $references;
}
else {
// We at least want to save the empty docblock so the file record is
// updated.
$docblocks = [$docblock];
if (!$error_logged) {
$this->logger->warning('File @name had no statements.',
[
'@name' => $docblock['file_name'],
]);
}
}
return $docblocks;
}
/**
* Parses a php snippet.
*
* @param string $code
* Code to parse.
*
* @return \PhpParser\Node\Stmt[]|null
* Array of statements or NULL.
*/
public function parsePhpCode($code) {
try {
$statements = $this->phpParser->parse("<?php " . $code . " ?>");
}
catch (ParserError $e) {
// Not valid PHP code.
return NULL;
}
return $statements;
}
/**
* Parses a Yaml file and returns its docblock information.
*
* @param array $docblock
* Information about the file to parse.
* @param \Drupal\api\Interfaces\BranchInterface $branch
* (Optional) Branch where this docblock is.
*
* @return array
* Docblock array containing the different elements within the file.
*/
public function parseYaml(array $docblock, ?BranchInterface $branch = NULL) {
// Just use the file name as the documentation, since the file contents
// are not good documentation.
$bare_docblock = $docblock;
$docblock['documentation'] = $docblock['file_name'];
// Parse the YAML in the file.
try {
$parsed = (new YamlParser())->parse($docblock['source'], Yaml::PARSE_CUSTOM_TAGS);
}
catch (\Exception $e) {
$parsed = [];
$this->logger->error('YAML parsing failed for %filename with message %msg',
[
'%filename' => $docblock['file_name'],
'%msg' => $e->getMessage(),
]);
}
// Find potential references, which are array values that look like they
// could be callback function names, in addition to some array keys. The
// level of keys we want depends on (a) if it's a Drupal file, and (b) the
// file extension.
$key_level = 0;
$is_services = FALSE;
if ($docblock['is_drupal']) {
$basename = $docblock['basename'];
$matches = [];
if (preg_match('|\.([^.]+)\.yml$|', $basename, $matches)) {
if ($matches[1] == 'services') {
// We want to keep references to 2nd-level keys in services.yml files.
$key_level = 2;
$is_services = TRUE;
}
elseif ($matches[1] == 'routing' || $matches[1] == 'local_tasks' || $matches[1] == 'contextual_links') {
// We want to keep references to 1st-level keys in routing.yml and
// related files.
$key_level = 1;
}
}
}
$references = $this->findYamlReferences($parsed, $key_level, $docblock['file_name']);
// For services files, we do not want the YML strings references, because
// really only the services are relevant.
if ($is_services) {
unset($references['yaml string']);
}
$docblock['references'] = $references;
// Format the code, number the lines and put into a code block.
// Escape HTML tags and entities.
$code = $docblock['source'];
$code = htmlspecialchars($code, ENT_NOQUOTES, 'UTF-8');
$code = Formatter::formatYamlCode($code, $references);
$code = Formatter::numberLines($code);
$code = Formatter::wrapPhpCode($code);
$code = Formatter::validateEncoding($code);
if ($is_services && is_array($parsed)) {
$code = Formatter::linkServiceAliasKeys($code, $parsed);
}
$docblock['code'] = $code;
$full_references = $references;
$docblocks = [$docblock];
// For services files, make docblocks for each service.
if ($is_services && isset($parsed['services'])) {
$docblocks = array_merge(
$docblocks,
$this->extractServices($parsed, $full_references, $bare_docblock)
);
}
return $docblocks;
}
/**
* Recursively finds potential references in a parsed YAML array.
*
* @param mixed $yaml
* Parsed YAML object.
* @param int $key_refs_level
* Store references to the keys on this level, where 1 is the current level.
* @param string $filename
* File name for watchdog messages.
*
* @return array
* Array of references suitable for use in $docblock['references'].
*/
protected function findYamlReferences($yaml, $key_refs_level, $filename) {
if (empty($yaml)) {
return ['potential callback' => []];
}
$yaml = (array) $yaml;
$references = [
'potential callback' => [],
'yaml string' => [],
];
foreach ($yaml as $key => $value) {
if ($key_refs_level == 1 && is_string($key)) {
$key = trim($key);
if ($key) {
$references['yaml string'][$key] = $key;
}
}
if (is_string($value)) {
$matches = [];
if (preg_match("|^['\"]*(" . self::RE_FUNCTION_IN_TEXT . ")['\"]*$|", $value, $matches)) {
// Special case the commonly-found TRUE and FALSE.
$val = trim($matches[1]);
if ($val && $val != 'TRUE' && $val != 'FALSE' && !is_numeric($val)) {
$references['potential callback'][$val] = $val;
}
}
}
elseif (is_array($value) || is_object($value)) {
$references = $this->mergeReferences(
$references,
$this->findYamlReferences($value, $key_refs_level - 1, $filename),
$filename
);
}
}
return $references;
}
/**
* Merges references, with warnings for duplicate namespaces.
*
* @param array $master
* Master list of references.
* @param array $new
* New references to merge in.
* @param string $filename
* File name for watchdog messages.
*
* @return array
* Merged references. References in $new are appended to references in
* $master, and if there are duplicate namespace or use references, the
* $master list is used and a warning is generated.
*/
protected function mergeReferences(array $master, array $new, $filename) {
// We're supporting only one namespace declaration per file.
if (isset($master['namespace']) && isset($new['namespace'])) {
if ($master['namespace'] != $new['namespace']) {
$this->logger->warning('Multiple namespace declarations found in file @file. Only first is used.',
[
'@file' => $filename,
]);
}
unset($new['namespace']);
}
// We're supporting only non-conflicting use declarations.
if (isset($new['use_alias']) && isset($master['use_alias'])) {
foreach ($new['use_alias'] as $alias => $class) {
if (isset($master['use_alias'][$alias]) && $master['use_alias'][$alias] != $class) {
$this->logger->warning('Conflicting use declarations for %name found in file @file. Only first is used.',
[
'%name' => $alias,
'@file' => $filename,
]);
unset($new['use_alias'][$alias]);
}
}
}
// Use array_replace_recursive here so we do not get duplicate references.
return array_replace_recursive($master, $new);
}
/**
* Parses a Text file and returns its docblock information.
*
* @param array $docblock
* Information about the file to parse.
* @param \Drupal\api\Interfaces\BranchInterface $branch
* (Optional) Branch where this docblock is.
* @param bool $escape_html
* If TRUE, escpae HTML characters in the source code listing.
*
* @return array
* Docblock array containing the different elements within the file.
*/
public function parseText(array $docblock, ?BranchInterface $branch = NULL, $escape_html = FALSE) {
// See if the file contains an @file block, and use that for the
// documentation if so; otherwise, just use the file as a whole. This is
// probably only present for Twig files.
$matches = [];
if (preg_match('|/\*\*[\s\*]+@file.+\*/|Us', $docblock['source'], $matches)) {
$docblock['content'] = Formatter::cleanComment($matches[0]);
}
else {
$tmp = [];
$docblock['documentation'] = Formatter::formatDocumentation($docblock['source'], FALSE, $tmp);
}
// Escape HTML and number the lines.
$output = $docblock['source'];
if ($escape_html) {
$output = htmlspecialchars($docblock['source'], ENT_NOQUOTES, 'UTF-8');
}
$output = Formatter::numberLines($output);
$output = Formatter::wrapPhpCode($output);
$output = Formatter::validateEncoding($output);
$docblock['code'] = $output;
return [
$docblock,
];
}
/**
* Parses a Twig file and returns its docblock information.
*
* @param array $docblock
* Information about the file to parse.
* @param \Drupal\api\Interfaces\BranchInterface $branch
* (Optional) Branch where this docblock is.
*
* @return array
* Docblock array containing the different elements within the file.
*/
public function parseTwig(array $docblock, ?BranchInterface $branch = NULL) {
// Use the text file function, but escape HTML characters.
return $this->parseText($docblock, $branch, TRUE);
}
/**
* Parses a markdown file and return its docblock information.
*
* @param array $docblock
* Information about the file to parse.
* @param \Drupal\api\Interfaces\BranchInterface $branch
* (Optional) Branch where this docblock is.
*
* @return array
* Docblock array containing the different elements within the file.
*/
public function parseMarkdown(array $docblock, ?BranchInterface $branch = NULL) {
$html_input = 'strip';
if ($branch instanceof BranchInterface) {
$html_input = $branch->getProject()->isTrusted() ? 'allow' : 'strip';
}
$code = $docblock['source'];
$code = Formatter::validateEncoding($code);
$docblock['code'] = '<pre>' . htmlspecialchars($code, ENT_NOQUOTES, 'UTF-8') . '</pre>';
$markdown_parser = new CommonMarkConverter([
'html_input' => $html_input,
'allow_unsafe_links' => FALSE,
]);
$docblock['documentation'] = $markdown_parser->convert($code);
// Demote h1 to h2 as we already have a h1.
$docblock['documentation'] = str_ireplace(
['<h1>', '</h1>'],
['<h2><b>', '</b></h2>'],
$docblock['documentation']
);
return [
$docblock,
];
}
/**
* Parses a Html file and returns its docblock information.
*
* @param array $docblock
* Information about the file to parse.
* @param \Drupal\api\Interfaces\BranchInterface $branch
* (Optional) Branch where this docblock is.
*
* @return array
* Docblock array containing the different elements within the file.
*/
public function parseHtml(array $docblock, ?BranchInterface $branch = NULL) {
$code = $docblock['source'];
$code = Formatter::validateEncoding($code);
$docblock['code'] = '<pre>' . htmlspecialchars($code, ENT_NOQUOTES, 'UTF-8') . '</pre>';
$title_match = [];
if (preg_match('!<title>([^<]+)</title>!is', $code, $title_match)) {
$docblock['title'] = trim($title_match[1]);
$docblock['summary'] = $docblock['title'];
}
$documentation_match = [];
if (preg_match('!<body>(.*?</h1>)?(.*)</body>!is', $code, $documentation_match)) {
$docblock['documentation'] = $documentation_match[2];
}
return [
$docblock,
];
}
/**
* Parses the additional services on a yaml file.
*
* @param array $parsed
* Parsed information object.
* @param array $full_references
* Full detailed references.
* @param array $bare_docblock
* Bare docblock array.
*
* @return array
* Additional docblock elements from parsing services.
*/
protected function extractServices(array $parsed, array $full_references, array $bare_docblock) {
$docblocks = [];
$dumper = new YamlDumper();
foreach ($parsed['services'] as $name => $info) {
// Service aliases are not useful.
if (str_contains($name, '\\') && is_string($info)) {
continue;
}
try {
$code = $dumper->dump($info, 2, 2);
}
catch (\Exception $e) {
// We should be able to dump, but just in case, fall back to printing,
// which is better than nothing.
$code = print_r($info, TRUE);
}
$code = htmlspecialchars($code, ENT_NOQUOTES, 'UTF-8');
$code = Formatter::formatYamlCode($code, $full_references);
$code = Formatter::numberLines($code);
$code = Formatter::wrapPhpCode($code);
$code = Formatter::validateEncoding($code);
$references = ['service_tag' => []];
$class = '';
if (isset($info['class']) && $info['class']) {
$class = $info['class'];
// Make sure the class name starts with a backslash.
$ref = $class;
$pos = strpos($ref, '\\');
if ($pos !== 0) {
$ref = '\\' . $ref;
}
$references['service_class'] = [$ref];
}
$alias = (isset($info['alias']) && $info['alias']) ? $info['alias'] : '';
$abstract = (isset($info['abstract']) && $info['abstract']);
if (isset($info['tags'])) {
foreach ($info['tags'] as $tag) {
$tag_name = FALSE;
if (is_string($tag)) {
$tag_name = $tag;
}
elseif (is_array($tag) && !empty($tag['name'])) {
$tag_name = $tag['name'];
}
if ($tag_name) {
$references['service_tag'][$tag_name] = $tag_name;
}
}
}
$service = [
'object_name' => $name,
'title' => $name,
'object_type' => 'service',
'code' => $code,
'source' => '',
'documentation' => $class ? $class : ($alias ? $this->t('Alias of %alias', ['%alias' => $alias]) : ($abstract ? $this->t('Abstract') : '')),
'references' => $references,
] + $bare_docblock;
$docblocks[] = $service;
}
return $docblocks;
}
/**
* Traverses PHP statements to find references.
*
* @param array $statements
* Array of statements to traverse from PhpParser parsing.
* @param bool $is_drupal
* TRUE if this is Drupal code; FALSE if not. This turns on recognition of
* things like hooks and theme calls.
* @param string $filename
* File name for watchdog messages.
* @param array $state
* (optional) Array to keep track of state for recursive calls.
*
* @return array
* Array of references found. References are hook invocations, function
* calls, etc., and they are put into an associative array where the keys
* are the types of references ('function', 'potential hook', etc.), and
* the values are arrays of the names of this type that were found.
*/
public function findPhpReferences(array $statements, $is_drupal, $filename, array $state = []) {
$references = [];
$invoke_function_info = DrupalPrettyPrinter::invokeFunctions();
$statement_count = 0;
foreach ($statements as $statement) {
$statement_count++;
if (!$statement || !is_object($statement)) {
// This could happen if some of the "sub-statements" in a recursive call
// were actually empty or scalars.
continue;
}
$type = $statement->getType();
$sub_statements = NULL;
$sub_state = $state;
// Find references that are directly in this statement.
if ($type == 'Expr_FuncCall') {
// Function call. Only store a reference if it is a directly-
// named function, not a variable.
$name = Formatter::asString($statement->name);
if ($name) {
$references['function'][$name] = $name;
if ($is_drupal && isset($invoke_function_info[$name])) {
$sub_state['invoke_call'] = $invoke_function_info[$name];
}
}
if (!empty($statement->args)) {
$sub_statements = $statement->args;
}
}
elseif ($type == 'Expr_MethodCall') {
// Method call. Only store a reference if it is a directly-named method,
// not a variable.
$name = Formatter::asString($statement->name);
// Save as a call reference if it's a method on $this.
if ($name && $statement->var && isset($statement->var->name) && $statement->var->name == 'this') {
$references['member-self'][$name] = $name;
}
if ($name && $is_drupal && isset($invoke_function_info[$name])) {
$sub_state['invoke_call'] = $invoke_function_info[$name];
}
if (!empty($statement->args)) {
$sub_statements = $statement->args;
}
}
elseif ($type == 'Expr_StaticCall') {
// Method call on a static class. Only save if it is a directly-named
// method, not a variable, on a directly-named class or 'self'.
$name = Formatter::asString($statement->name);
$class = self::extractClassName($statement);
if ($name && $class) {
if ($class == 'self' || $class == 'static') {
$references['member-self'][$name] = $name;
}
elseif ($class == 'parent') {
$references['member-parent'][$name] = $name;
}
else {
$references['member-class'][$class . '::' . $name] = $class . '::' . $name;
}
if ($is_drupal && isset($invoke_function_info[$name])) {
$sub_state['invoke_call'] = $invoke_function_info[$name];
}
}
if (!empty($statement->args)) {
$sub_statements = $statement->args;
}
}
elseif ($type == 'Expr_ConstFetch') {
// Reference to a constant.
$name = Formatter::asString($statement->name);
$references['constant'][$name] = $name;
}
elseif ($type == 'Expr_ClassConstFetch') {
// Reference to a class constant. Only store if it is a directly-named
// class, not a variable like $myclass.
$class = self::extractClassName($statement);
if ($class) {
$name = Formatter::asString($statement->name);
$references['constant'][$name] = $class . '::' . $name;
}
}
elseif ($type == 'Scalar_String') {
$name = $statement->value;
if ($name) {
if (!empty($state['invoke_call'])) {
$references['potential ' . $state['invoke_call'][0]][$name] = $name;
}
elseif ($is_drupal && isset($state['array_key']) && $state['array_key'] == '#theme') {
$references['potential theme'][$name] = $name;
}
elseif ($is_drupal && isset($state['array_key']) && $state['array_key'] == '#type') {
$references['potential element'][$name] = $name;
}
elseif (preg_match("|^" . self::PHP_FUNCTION_PATTERN . "$|", $name)) {
$references['potential callback'][$name] = $name;
}
if (preg_match("|^" . self::RE_FILENAME . "$|", $name)) {
// Some of these may be quite long, so truncate.
$newname = mb_substr($name, 0, 127);
$references['potential file'][$newname] = $newname;
}
}
}
elseif ($type == 'Arg') {
// Function argument.
$sub_statements = $statement->value;
}
elseif ($type == 'ArrayItem') {
// Array item.
if ($statement->key && $statement->key->getType() == 'Scalar_String') {
$sub_state['array_key'] = $statement->key->value;
}
$sub_statements = $statement->value;
}
elseif ($statement instanceof NodeClassLike) {
// Do not recurse into classes.
$sub_statements = [];
}
elseif (strpos($type, 'Scalar') !== 0) {
// Handle things with sub-statements.
$sub_types = [
'expr',
'left',
'right',
'vars',
'items',
'value',
'stmts',
'args',
'cond',
'if',
'else',
'elseifs',
'init',
'loop',
'cases',
'catches',
'finally',
];
$sub_statements = [];
foreach ($sub_types as $thing) {
if (isset($statement->$thing)) {
$to_add = $statement->$thing;
if (!is_array($to_add)) {
$sub_statements[] = $to_add;
}
else {
foreach ($to_add as $item) {
$sub_statements[] = $item;
}
}
}
}
}
// Recursively find references in sub-statements.
if (!empty($sub_statements)) {
if (!is_array($sub_statements)) {
$sub_statements = [$sub_statements];
}
$references = $this->mergeReferences(
$references,
$this->findPhpReferences($sub_statements, $is_drupal, $filename, $sub_state),
$filename
);
}
// After processing the argument of functions where a hook name could be,
// remove the possibility of finding more matches in later arguments.
if (isset($state['invoke_call']) &&
is_array($state['invoke_call']) &&
$statement_count >= $state['invoke_call'][1]) {
$state['invoke_call'] = FALSE;
}
}
return $references;
}
/**
* Extracts the class name from a statement.
*
* @param object $statement
* Statement to extract the class name from.
*
* @return string
* Class name, if the statement has one; empty string otherwise.
*/
public static function extractClassName($statement) {
if (!$statement->class) {
return '';
}
// $statement->class is an object, hopefully some type of a "name".
$type = $statement->class->getType();
if ($type != 'Name' && strpos($statement->class->getType(), 'Name_') !== 0) {
return '';
}
$class = $statement->class->toString();
if ($statement->class->isFullyQualified()) {
$class = '\\' . $class;
}
return $class;
}
/**
* Builds documentation blocks and finds references for parsed PHP code.
*
* @param array $statements
* An array of PHP parser output statements to look through.
* @param array $default_block
* The default documentation block to use.
* @param array $docblocks
* The array of documentation blocks, passed by reference. Documentation and
* code items found in the PHP statements are added to the end of the array.
*
* @return array
* An array of all the references found while parsing the statements.
*/
public function buildPhpDocblocks(array $statements, array $default_block, array &$docblocks) {
// Keep track of all references found.
$all_references = [];
// Traverse top-level statement list to gather documentation items.
$in_class = !empty($default_block['class']);
$class_prefix = $in_class ? $default_block['class'] . '::' : '';
$printer = new DrupalPrettyPrinter(['html' => TRUE]);
/** @var \PhpParser\Node\Stmt $statement */
foreach ($statements as $statement) {
$docblock = $default_block;
$docblock['start_line'] = $statement->getStartLine();
$docblock['content'] = '';
if (!empty($statement->attrGroups)) {
foreach ($statement->attrGroups as $attrGroup) {
foreach ($attrGroup->attrs as $attribute) {
if ($attribute->args && $attribute->args[0]->value instanceof String_) {
// For #[FooAttribute('bar', 'baz')] this stores
// $docblock['php_attributes']['FooAttribute'] = ['bar']
// and nothing else. This is enough both for hook and elements.
// Namespace resolving for the attribute class happens later.
$docblock['php_attributes'][Formatter::asString($attribute->name)][] = $attribute->args[0]->value->value;
}
}
}
$statement->attrGroups[0]->setAttribute('comments', []);
$docblock['php_attributes_documentation'] = '<div class="php-attributes">' . $printer->prettyPrint($statement->attrGroups) . '</div>';
}
$type = $statement->getType();
// Process the comments for this statement. The parser makes an array of
// all the comments that precede the statement; the last one is the doc
// block (for statements that support doc blocks). Other than the official
// doc bloc, other ones can be saved as global doc blocks, such as @file
// and @defgroup doc blocks, but only if we are outside of classes.
$comments = array_values(array_filter((array) $statement->getAttribute('comments'), fn ($comment) => $comment instanceof CommentDoc));
$types_without_comments = [
'Stmt_Nop',
'Stmt_Namespace',
'Stmt_Use',
];
if ($comments) {
$count = count($comments);
foreach ($comments as $index => $comment) {
if (!$in_class && ($index < $count - 1 || in_array($type, $types_without_comments))) {
// This is a global comment. Add it to the list of doc blocks.
$comment_docblock = $default_block;
$comment_docblock['content'] = Formatter::cleanComment($comment->getText());
$comment_docblock['start_line'] = $comment->getStartLine();
$docblocks[] = $comment_docblock;
}
elseif ($index == $count - 1) {
// This is the doc comment for this statement.
$docblock['content'] = Formatter::cleanComment($comment->getText());
}
}
}
// Clear out the comments, so that we don't encounter them later.
$statement->setAttribute('comments', []);
// Process the actual statement.
switch ($type) {
case 'Expr_FuncCall':
case 'Stmt_Expression':
// Process this only if it is a call to define(CONST_NAME, value);.
$name = (!empty($statement->name)) ? $statement->name->toString() : NULL;
if (is_null($name) && !empty($statement->expr)) {
$name = (!empty($statement->expr->name)) ? $statement->expr->name->toString() : NULL;
}
if ($name == 'define') {
$args = !empty($statement->args) ? $statement->args : NULL;
if (is_null($args) && !empty($statement->expr)) {
$args = !empty($statement->expr->args) ? $statement->expr->args : [];
}
if (count($args) > 0) {
$value = $args[0]->value;
if (isset($value->value)) {
$value = $value->value;
}
elseif (isset($value->name)) {
$value = $value->name;
}
$docblock['object_type'] = 'constant';
$docblock['member_name'] = Formatter::asString($value);
$docblock['object_name'] = $class_prefix . $docblock['member_name'];
$docblock['title'] = $docblock['object_name'];
$docblock['code'] = Formatter::formatStatements([$statement], $docblock['is_drupal']);
$docblocks[] = $docblock;
}
}
break;
case 'Stmt_ClassConst':
case 'Stmt_Const':
case 'Stmt_Property':
case 'Stmt_Global':
$sub_objects = [];
$title_prefix = '';
if ($type == 'Stmt_ClassConst' || $type == 'Stmt_Const') {
$sub_objects = $statement->consts;
$docblock['object_type'] = 'constant';
}
elseif ($type == 'Stmt_Property') {
$sub_objects = $statement->props;
$docblock['object_type'] = 'property';
$title_prefix = '$';
}
elseif ($type == 'Stmt_Global') {
$sub_objects = $statement->vars;
$docblock['object_type'] = 'global';
$title_prefix = '$';
}
if (!empty($sub_objects)) {
$sub_object = $sub_objects[0];
$docblock['member_name'] = Formatter::asString($sub_object->name);
$docblock['object_name'] = $class_prefix . $docblock['member_name'];
$docblock['title'] = $class_prefix . $title_prefix . $docblock['member_name'];
$docblock['code'] = Formatter::formatStatements([$statement], $docblock['is_drupal']);
if ($type != 'Stmt_Global' && $type != 'Stmt_Const') {
$docblock['modifiers'] = $this->getStatementModifiers($statement);
}
$docblocks[] = $docblock;
}
break;
case 'Stmt_Function':
case 'Stmt_ClassMethod':
$docblock['object_type'] = 'function';
$docblock['member_name'] = Formatter::asString($statement->name);
$docblock['object_name'] = $class_prefix . $docblock['member_name'];
$docblock['title'] = $docblock['object_name'];
$docblock['code'] = Formatter::formatStatements([$statement], $docblock['is_drupal']);
$docblock['references'] = $this->findPhpReferences([$statement], $docblock['is_drupal'], $docblock['file_name']);
$all_references = $this->mergeReferences($all_references, $docblock['references'], $docblock['file_name']);
$docblock['signature'] = $this->getFunctionSignature($statement);
if ($type == 'Stmt_ClassMethod') {
$docblock['modifiers'] = $this->getStatementModifiers($statement);
}
$docblocks[] = $docblock;
break;
case 'Stmt_Class':
case 'Stmt_Interface':
case 'Stmt_Trait':
$docblock['object_name'] = Formatter::asString($statement->name);
$docblock['title'] = $docblock['object_name'];
$docblock['member_name'] = $docblock['object_name'];
// Note that we are not finding references here. We use the ones
// from the child statements instead.
$docblock['code'] = Formatter::formatStatements([$statement], $docblock['is_drupal']);
$docblock['extends'] = [];
$docblock['implements'] = [];
if ($type == 'Stmt_Class') {
$docblock['object_type'] = 'class';
$docblock['modifiers'] = $this->getStatementModifiers($statement);
if ($statement->extends) {
$docblock['extends'] = [$statement->extends->toString()];
}
if (!empty($statement->implements) && count($statement->implements)) {
foreach ($statement->implements as $item) {
$docblock['implements'][] = $item->toString();
}
}
}
elseif ($type == 'Stmt_Interface') {
$docblock['object_type'] = 'interface';
if ($statement->extends) {
foreach ($statement->extends as $extend) {
$docblock['extends'][] = $extend->toString();
}
}
}
else {
$docblock['object_type'] = 'trait';
}
$docblocks[] = $docblock;
// Process the class's internal/body statements.
if (!empty($statement->stmts)) {
$last_index = count($docblocks) - 1;
$references = $this->buildPhpDocblocks($statement->stmts, array_merge($default_block, ['class' => $docblock['object_name']]), $docblocks);
$all_references = $this->mergeReferences($all_references, $references, $docblock['file_name']);
$docblocks[$last_index]['references'] = $references;
}
break;
case 'Stmt_Namespace':
if ($statement->name) {
$namespace = $statement->name->toString();
$references = ['namespace' => $namespace];
$all_references = $this->mergeReferences($all_references, $references, $docblock['file_name']);
}
else {
$this->logger->warning('Empty namespace declaration in file @name',
[
'@name' => $docblock['file_name'],
]);
}
// The rest of the statements in this file are inside this namespace.
if (!empty($statement->stmts)) {
$references = $this->buildPhpDocblocks($statement->stmts, $default_block, $docblocks);
$all_references = $this->mergeReferences($all_references, $references, $docblock['file_name']);
}
break;
case 'Stmt_Use':
$references = ['use_alias' => []];
foreach ($statement->uses as $use) {
$alias = $use->alias;
$class = $use->name->toString();
if (!$alias) {
// Strip out namespace info from alias part.
$parts = explode('\\', $class);
$alias = array_pop($parts);
}
$alias = Formatter::asString($alias);
$references['use_alias'][$alias] = $class;
}
$all_references = $this->mergeReferences($all_references, $references, $docblock['file_name']);
break;
case 'Stmt_TraitUse':
$trait = $statement->traits[0]->toString();
$references = [
'use_trait' => [
$trait => [
'class' => $trait,
'details' => [],
],
],
];
foreach ($statement->adaptations as $adaptation) {
$ad_type_parts = explode('_', $adaptation->getType());
$ad_type = strtolower(array_pop($ad_type_parts));
if ($ad_type == 'precedence') {
foreach ($adaptation->insteadof as $node) {
$references['use_trait'][$trait]['details'][$ad_type][$node->toString()] = $adaptation->method;
}
}
else {
$adaptationName = Formatter::asString($adaptation->newName);
$references['use_trait'][$trait]['details'][$ad_type][$adaptationName] = $adaptation->method;
}
}
$all_references = $this->mergeReferences($all_references, $references, $docblock['file_name']);
break;
}
}
return $all_references;
}
/**
* Returns the function signature from a PhpParser function node object.
*
* @param object $statement
* A function statement to get the signature of.
*
* @return string
* The function signature.
*/
public function getFunctionSignature($statement) {
// Make a function with empty body, pretty-print it, and remove the {}.
$empty_function = new NodeFunction(
$statement->name,
[
'byRef' => $statement->byRef,
'params' => $statement->params,
'returnType' => $statement->returnType,
]);
// Note: Use the Standard pretty-printer here, not our class that does
// HTML formatting.
$printer = new StandardPrettyPrinter();
$output = $printer->prettyPrint([$empty_function]);
$output = preg_replace('|\{.*\}|s', '', $output);
$output = str_replace('function ', '', $output);
return trim($output);
}
/**
* Returns the modifiers from a PhpParser statement.
*
* @param object $statement
* A class, method, property, etc. statement to get the modifiers of. Must
* have a flags property.
*
* @return string
* The modifiers.
*/
public function getStatementModifiers($statement) {
$flags = $statement->flags;
$modifiers = '';
$modifier_list = [
// Note: Keep this list in the order that the modifiers should appear.
Modifiers::ABSTRACT => 'abstract',
Modifiers::FINAL => 'final',
Modifiers::PUBLIC => 'public',
Modifiers::PROTECTED => 'protected',
Modifiers::PRIVATE => 'private',
Modifiers::STATIC => 'static',
];
foreach ($modifier_list as $flag => $name) {
if ($flags & $flag) {
$modifiers .= $name . ' ';
}
}
return trim($modifiers);
}
/**
* Look for @file block first so $docblocks[0] gets filled in first.
*
* @param array $docblocks
* Array of docblocks.
*
* @return array
* Same array with the 'content' property altered if needed.
*/
public function fileDocblockFirst(array $docblocks) {
foreach ($docblocks as $docblock) {
if (
!empty($docblock['content']) &&
preg_match('/' . Parser::RE_TAG_START . 'file/', $docblock['content'])
) {
$content = $docblock['content'];
// Remove @file tag from this docblock.
$content = str_replace('@file', '', $content);
// If this docblock contains @mainpage or @defgroup, this will cause
// problems, because we won't have a @file doc block any more -- it will
// be co-opted, and then the site will be screwed up. So, remove these
// tags and save a watchdog message.
if (
preg_match('/' . Parser::RE_TAG_START . 'mainpage/', $content) ||
preg_match('/' . Parser::RE_TAG_START . 'defgroup/', $content)
) {
$content = str_replace('@mainpage', '', $content);
$content = str_replace('@defgroup', '', $content);
$this->logger->warning(
'@file docblock containing @defgroup or @mainpage in %file at line %line. Extraneous tags ignored.',
[
'%file' => $docblocks[0]['file_name'],
'%line' => $docblock['start_line'],
]
);
}
$docblocks[0]['content'] = $content;
break;
}
}
return $docblocks;
}
/**
* Splits certain tags into further docblocks.
*
* @param array $docblocks
* Original set of docblocks.
* @param array|string[] $tags
* Tags to split.
*
* @return array
* New set of docblocks with the new elements added.
*/
public function splitByTags(array $docblocks, array $tags = []) {
if (empty($tags)) {
return $docblocks;
}
$old_blocks = $docblocks;
$docblocks = [];
$tags_regex = implode('|', $tags);
foreach ($old_blocks as $docblock) {
if (
$docblock['code'] && $docblock['content'] &&
preg_match('/' . Parser::RE_TAG_START . '(' . $tags_regex . ')/', $docblock['content'])
) {
$new_block = $docblock;
// Make one block have just the code and the other, just the docs.
$new_block['code'] = '';
$docblock['content'] = '';
$docblocks[] = $new_block;
$docblocks[] = $docblock;
$this->logger->warning(
'Item docblock containing @tags tags in %file at line %line. Separated into two blocks.',
[
'%file' => $docblocks[0]['file_name'],
'%line' => $docblock['start_line'],
'@tags' => $tags_regex,
]
);
}
else {
$docblocks[] = $docblock;
}
}
return $docblocks;
}
/**
* Return the references namespace.
*
* @param array $docblock
* Docblock array.
*
* @return string|null
* Namespace or null if nothing was found.
*/
protected function getReferencesNamespace(array $docblock) {
return (isset($docblock['references']['namespace']) && !empty($docblock['references']['namespace'])) ?
$docblock['references']['namespace'] :
NULL;
}
/**
* Gets the use_alias property of a docblock.
*
* @param array $docblock
* Docblock array.
*
* @return array
* Aliases array.
*/
protected function getUseAlias(array $docblock) {
return (isset($docblock['references']['use_alias'])) ?
$docblock['references']['use_alias'] :
[];
}
/**
* Change annotation tag to ingroup if it's Drupal code.
*
* @param array $docblock
* Docblock array.
*/
protected function changeAnnotationToInGroup(array &$docblock) {
$annotation_matches = [];
$docblock['annotation_class'] = FALSE;
if (preg_match('/' . self::RE_TAG_START . 'Annotation' . self::RE_WORD_BOUNDARY_END . '/', $docblock['content'], $annotation_matches)) {
if ($docblock['is_drupal']) {
$docblock['content'] = str_replace($annotation_matches[0], "\n@ingroup annotation\n", $docblock['content']);
}
$docblock['annotation_class'] = TRUE;
}
}
/**
* Change event tag to ingroup.
*
* @param array $docblock
* Docblock array.
*/
protected function changeEventToInGroup(array &$docblock) {
$event_matches = [];
if (preg_match('/' . self::RE_TAG_START . 'Event' . self::RE_WORD_BOUNDARY_END . '/', $docblock['content'], $event_matches)) {
$docblock['content'] = str_replace($event_matches[0], "\n@ingroup events\n", $docblock['content']);
}
}
/**
* Checks if a docblock has a tag in its contents.
*
* @param array $docblock
* Docblock array.
* @param string $tag
* Tag to check.
*
* @return bool
* Whether the tag is present or not.
*/
protected function docblockHasTag(array $docblock, $tag) {
return preg_match('/' . self::RE_TAG_START . $tag . '/', $docblock['content']);
}
/**
* Sets the mainpage tag.
*
* @param array $docblock
* Docblock array.
*/
protected function setMainpage(array &$docblock) {
$matches = [];
preg_match('/' . self::RE_TAG_START . 'mainpage(.*?)\n/', $docblock['content'], $matches);
if (!empty($matches)) {
$docblock['title'] = (isset($matches[1]) ? trim($matches[1]) : '');
$docblock['content'] = preg_replace('/' . self::RE_TAG_START . 'mainpage.*?\n/', '', $docblock['content']);
$docblock['object_type'] = 'mainpage';
}
}
/**
* Sets group membership.
*
* @param array $docblock
* Docblock array.
*/
protected function setGroupMembership(array &$docblock) {
$matches = [];
if (preg_match_all('/' . self::RE_TAG_START . '(ingroup|addtogroup) ([a-zA-Z0-9_.-]+)/', $docblock['content'], $matches)) {
$docblock['groups'] = $matches[2];
$docblock['content'] = preg_replace('/' . self::RE_TAG_START . '(ingroup|addtogroup).*?\n/', '', $docblock['content']);
}
}
/**
* Returns a defgroup name in a docblock, if any.
*
* @param array $docblock
* Docblock array.
* @param bool $all_matches
* Return all matches or just the group name.
*
* @return mixed|null
* Match or null.
*/
protected function getDefgroupName(array $docblock, $all_matches = FALSE) {
$matches = [];
preg_match('/' . self::RE_TAG_START . 'defgroup ([a-zA-Z0-9_.-]+) +(.*?)\n/', $docblock['content'], $matches);
if ($all_matches && !empty($matches)) {
return $matches;
}
return !empty($matches) ?
$matches[1] :
NULL;
}
/**
* Sets the defgroup tag.
*
* @param array $docblock
* Docblock array.
*/
protected function setDefgroup(array &$docblock) {
$matches = $this->getDefgroupName($docblock, TRUE);
if (!empty($matches)) {
$docblock['object_name'] = $matches[1];
$docblock['title'] = $matches[2];
$docblock['content'] = preg_replace('/' . self::RE_TAG_START . 'defgroup.*?\n/', '', $docblock['content']);
$docblock['object_type'] = 'group';
}
else {
$this->logger->warning(
'Malformed @defgroup in %file at line %line.',
[
'%file' => $docblock['file_name'],
'%line' => $docblock['start_line'],
]
);
}
}
/**
* Replaces a tag in a docblock array.
*
* @param array $docblock
* Docblock array.
* @param string $search
* String to search.
* @param string $replace
* String to replace.
* @param bool $warning
* Log a warning to the watchdog about this.
*/
protected function replaceTag(array &$docblock, $search, $replace, $warning = FALSE) {
$docblock['content'] = str_replace($search, $replace, $docblock['content']);
if ($warning) {
$this->logger->warning(
'Replaced @search in %file at line %line with @replace.',
[
'%file' => $docblock['file_name'],
'%line' => $docblock['start_line'],
'@search' => $search,
'@replace' => $replace,
]
);
}
}
/**
* Handle nested_groups for a docblock.
*
* @param array $docblock
* Docblock array.
* @param array $nested_groups
* Current nested groups.
*/
protected function setNestedGroups(array &$docblock, array &$nested_groups) {
if (!isset($nested_groups[$docblock['class']])) {
$nested_groups[$docblock['class']] = [];
}
foreach ($nested_groups[$docblock['class']] as $group_id) {
if (!empty($group_id)) {
$docblock['groups'][] = $group_id;
}
}
if (preg_match('/' . self::RE_TAG_START . '{/', $docblock['content'])) {
if ($docblock['object_type'] === 'group') {
array_push($nested_groups[$docblock['class']], $docblock['object_name']);
}
elseif (isset($docblock['groups'])) {
array_push($nested_groups[$docblock['class']], reset($docblock['groups']));
}
else {
array_push($nested_groups[$docblock['class']], '');
}
}
if (preg_match('/' . self::RE_TAG_START . '}/', $docblock['content'])) {
array_pop($nested_groups[$docblock['class']]);
}
}
/**
* Do some processing of the docblock to add additional information.
*
* @param array $docblock
* Docblock array.
* @param string $namespace
* Namespace where this docblock belongs.
* @param array $use_aliases
* Aliases in use in this docblock.
* @param array $nested_groups
* Nested groups in this docblock.
* @param array $class_ids
* Array of documentation ids.
* @param \Drupal\api\Interfaces\BranchInterface $branch
* Branch this docblock will be attached to.
*
* @return bool
* Whether we should save this or not.
*/
public function processDocblock(array &$docblock, &$namespace, array &$use_aliases, array &$nested_groups, array &$class_ids, BranchInterface $branch) {
// Keep track of the namespace and add it to all docblocks for this file.
$namespace = $this->getReferencesNamespace($docblock) ?? $namespace;
// Keep track of the use aliases so we can put the right classes into the
// extends/implements references for classes we encounter.
$use_aliases = array_merge($this->getUseAlias($docblock), $use_aliases);
// Start filling additional data for the docblock.
$docblock['namespace'] = $namespace;
$this->changeAnnotationToInGroup($docblock);
$this->changeEventToInGroup($docblock);
if ($this->docblockHasTag($docblock, 'mainpage')) {
$this->setMainpage($docblock);
$docblock['object_name'] = $branch->getSlug();
}
elseif ($this->docblockHasTag($docblock, 'defgroup')) {
$group_name = $this->getDefgroupName($docblock);
$existing_defgroup = !empty($group_name) ?
DocBlock::findByDefgroup($group_name, $branch, $docblock['file_name']) :
NULL;
if ($existing_defgroup) {
$this->replaceTag($docblock, 'defgroup', 'addtogroup', TRUE);
// No need to reparse anything as we're already changing the tag and
// there is a defgroup in place.
}
else {
$this->setDefgroup($docblock);
}
}
$this->setGroupMembership($docblock);
$this->setNestedGroups($docblock, $nested_groups);
// At this point, we might have been dealing with a "block" that is
// just an @} or an object with no name, or something like that. We needed
// to do the processing above, but we don't want to save this as an object.
if (empty($docblock['object_type']) || empty($docblock['object_name'])) {
return FALSE;
}
$this->replaceTag($docblock, '{@inheritdoc}', '');
$this->replaceTag($docblock, '{@inheritDoc}', '');
if (($docblock['content'] && trim($docblock['content'])) || !empty($docblock['php_attributes_documentation'])) {
$this->setParams($docblock);
$this->setReturn($docblock);
$this->setSee($docblock);
$this->setVar($docblock);
$this->setThrows($docblock);
$this->setDeprecated($docblock);
$this->setDocumentation($docblock);
}
// Grab the first line as a summary, unless already provided.
if (!isset($docblock['summary'])) {
$docblock['summary'] = Formatter::documentationSummary($docblock['documentation']);
}
if (!empty($docblock['class'])) {
$docblock['class'] = $class_ids[$docblock['class']];
}
if (!empty($docblock['code'])) {
$docblock['code'] = Formatter::validateEncoding($docblock['code']);
}
// Figure out the namespaced name.
$docblock['namespaced_name'] = Formatter::fullClassname($docblock['object_name'], $namespace, $use_aliases);
// Figure out the namespaced name for attributes.
foreach ($docblock['php_attributes'] ?? [] as $class => $values) {
$docblock['php_attributes'][Formatter::fullClassname($class, $namespace, $use_aliases)] = $values;
unset($docblock['php_attributes'][$class]);
}
return TRUE;
}
/**
* Sets the param annotations into the docblock.
*
* @param array $docblock
* Docblock array.
*/
protected function setParams(array &$docblock) {
$matches = [];
$tmp = [];
$params = '';
while (preg_match('/' . self::RE_TAG_START . 'param\s(.*?)(?=\n' . self::RE_TAG_START . '|$)/s', $docblock['content'], $matches)) {
$docblock['content'] = str_replace($matches[0], '', $docblock['content']);
// Add some formatting to the parameter -- strong tag for everything
// that was on the @param line, and a colon after. Note that tags
// are stripped out below, so we use [strong] and then fix it later.
$this_param = $matches[1];
$this_param = preg_replace('|^([^\n]+)|', '[strong]$1[/strong]:', $this_param);
$params .= "\n\n" . $this_param;
}
// Format and then replace our fake tags with real ones.
$params = Formatter::formatDocumentation($params, TRUE, $tmp);
$params = str_replace('[strong]', '<strong>', $params);
$params = str_replace('[/strong]', '</strong>', $params);
$docblock['parameters'] = $params;
}
/**
* Sets the return annotations into the docblock.
*
* @param array $docblock
* Docblock array.
*/
protected function setReturn(array &$docblock) {
$matches = [];
$tmp = [];
$docblock['return_value'] = '';
preg_match_all('/' . self::RE_TAG_START . 'return\s(.*?)(?=\n' . self::RE_TAG_START . '|$)/s', $docblock['content'], $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$docblock['content'] = str_replace($match[0], '', $docblock['content']);
$docblock['return_value'] .= "\n\n" . $match[1];
}
$docblock['return_value'] = Formatter::formatDocumentation($docblock['return_value'], TRUE, $tmp);
}
/**
* Sets the see annotations into the docblock.
*
* @param array $docblock
* Docblock array.
*/
protected function setSee(array &$docblock) {
$this->setProperty($docblock, 'see', '/' . self::RE_TAG_START . 'see\s(.*?)\n/s');
}
/**
* Sets the var annotations into the docblock.
*
* @param array $docblock
* Docblock array.
*/
protected function setVar(array &$docblock) {
$matches = [];
$docblock['var'] = '';
if (preg_match('/' . self::RE_TAG_START . 'var\s(.*?)\n/s', $docblock['content'], $matches)) {
$docblock['content'] = str_replace($matches[0], '', $docblock['content']);
$docblock['var'] = trim($matches[1]);
}
}
/**
* Sets the throws annotations into the docblock.
*
* @param array $docblock
* Docblock array.
*/
protected function setThrows(array &$docblock) {
$this->setProperty($docblock, 'throws');
}
/**
* Sets the deprecated annotations into the docblock.
*
* @param array $docblock
* Docblock array.
*/
protected function setDeprecated(array &$docblock) {
$this->setProperty($docblock, 'deprecated');
}
/**
* Generic setter for docblock properties.
*
* @param array $docblock
* Docblock array.
* @param string $property
* Property to set.
* @param string $regex
* Regex to use.
*/
protected function setProperty(array &$docblock, $property, $regex = NULL) {
$matches = [];
$tmp = [];
if (is_null($regex)) {
$regex = '/' . self::RE_TAG_START . $property . '\s(.*?)(?=\n' . self::RE_TAG_START . '|$)/s';
}
$docblock[$property] = '';
while (preg_match($regex, $docblock['content'], $matches)) {
// Replace the first occurrence as similar namespaces could be truncated.
$pos = strpos($docblock['content'], $matches[0]);
if ($pos !== FALSE) {
$docblock['content'] = substr_replace($docblock['content'], '', $pos, strlen($matches[0]));
}
$docblock[$property] .= "\n\n" . $matches[1];
}
$docblock[$property] = Formatter::formatDocumentation($docblock[$property], TRUE, $tmp);
}
/**
* Sets the documentation annotations into the docblock.
*
* @param array $docblock
* Docblock array.
*/
protected function setDocumentation(array &$docblock) {
// Format everything remaining as the main documentation.
$docblock['documentation'] = Formatter::formatDocumentation($docblock['content'], TRUE, $docblock['references'], $docblock['php_attributes_documentation'] ?? '');
}
/**
* Adds defaults for TEXT fields to a database record.
*
* These cannot come from the schema, because TEXT fields have no defaults.
*
* @param array $record
* Record about to be written to the database, passed by reference.
* @param string $entity_type
* Entity type $record is going into.
*/
public function addTextDefaults(array &$record, $entity_type = 'docblock') {
switch ($entity_type) {
case 'docblock':
$record += [
'summary' => '',
'documentation' => '',
'code' => '',
'see' => '',
'deprecated' => '',
'var' => '',
'throws' => '',
'namespace' => '',
'namespaced_name' => '',
'modifiers' => '',
];
break;
case 'docblock_function':
$record += [
'parameters' => '',
'return_value' => '',
];
break;
}
}
}
