Add Safe PHP functions

This commit is contained in:
Alex Cabal 2019-03-07 12:11:50 -06:00
parent 04a956886a
commit 58cc098058
260 changed files with 49458 additions and 45 deletions

View file

@ -0,0 +1,30 @@
<?php
namespace Safe;
/**
* This class will edit the main composer.json file to add the list of files generated from modules.
*/
class ComposerJsonEditor
{
/**
* @param string[] $modules A list of modules
*/
public static function editFiles(array $modules): void
{
$files = \array_map(function (string $module) {
return 'generated/'.lcfirst($module).'.php';
}, $modules);
$files[] = 'lib/special_cases.php';
$composerContent = file_get_contents(__DIR__.'/../../composer.json');
if ($composerContent === false) {
throw new \RuntimeException('Error while loading composer.json file for edition.');
}
$composerJson = \json_decode($composerContent, true);
$composerJson['autoload']['files'] = $files;
$newContent = json_encode($composerJson, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES);
\file_put_contents(__DIR__.'/../../composer.json', $newContent);
}
}

View file

@ -0,0 +1,211 @@
<?php
namespace Safe;
use function explode;
use function strpos;
class DocPage
{
/*
* @var string
*/
private $path;
/*
* @return string
* @parameter string
*/
public function __construct(string $_path)
{
$this->path = $_path;
}
/*
* Detect function which didn't return FALSE on error.
*
* @return bool
*/
public function detectFalsyFunction(): bool
{
$file = file_get_contents($this->path);
if (preg_match('/&warn\.deprecated\.function-(\d+-\d+-\d+)\.removed-(\d+-\d+-\d+)/', $file, $matches)) {
$removedVersion = $matches[2];
[$major, $minor] = explode('-', $removedVersion);
if ($major < 7 || ($major == 7 && $minor == 0)) {
// Ignore function if it was removed before PHP 7.1
return false;
}
}
if (preg_match('/&warn\.removed\.function-(\d+-\d+-\d+)/', $file, $matches) && isset($matches[2])) {
$removedVersion = $matches[2];
[$major, $minor] = explode('-', $removedVersion);
if ($major < 7 || ($major == 7 && $minor == 0)) {
// Ignore function if it was removed before PHP 7.1
return false;
}
}
if (preg_match('/&false;\s+on\s+error/m', $file)) {
return true;
}
if (preg_match('/&false;\s+on\s+failure/m', $file)) {
return true;
}
if (preg_match('/&false;\s+otherwise/m', $file) && !preg_match('/(returns\s+&true;|&true;\s+on\s+success|&true;\s+if)/im', $file)) {
return true;
}
if (preg_match('/may\s+return\s+&false;/m', $file) && !preg_match('/(returns\s+&true;|&true;\s+on\s+success|&true;\s+if)/im', $file)) {
return true;
}
if (preg_match('/&false;\s+if\s+an\s+error\s+occurred/m', $file)) {
return true;
}
if (preg_match('/&return.success;/m', $file)) {
return true;
}
if (preg_match('/&return.nullorfalse;/m', $file)) {
return true;
}
if (preg_match('/&return.falseforfailure;/m', $file)) {
return true;
}
if (preg_match('/&date.datetime.return.modifiedobjectorfalseforfailure;/m', $file)) {
return true;
}
if (preg_match('/ or &false; \\(and generates an error/m', $file)) {
return true;
}
if (preg_match('/&false;\s+if\s+the\s+number\s+of\s+elements\s+for\s+each\s+array\s+isn\'t\s+equal/m', $file)) {
return true;
}
if (preg_match('/If\s+the\s+call\s+fails,\s+it\s+will\s+return\s+&false;/m', $file)) {
return true;
}
return false;
}
/**
* @return \SimpleXMLElement[]
*/
public function getMethodSynopsis(): array
{
/** @var string[] $cleanedFunctions */
$cleanedFunctions = [];
$file = \file_get_contents($this->path);
if (!preg_match_all('/<\/?methodsynopsis[\s\S]*?>[\s\S]*?<\/methodsynopsis>/m', $file, $functions, PREG_SET_ORDER, 0)) {
return [];
}
$functions = $this->arrayFlatten($functions);
foreach ($functions as $function) {
$cleaningFunction = \str_replace(['&false;', '&true;', '&null;'], ['false', 'true', 'null'], $function);
$cleaningFunction = preg_replace('/&(.*);/m', '', $cleaningFunction);
if (!\is_string($cleaningFunction)) {
throw new \RuntimeException('Error occured in preg_replace');
}
$cleanedFunctions[] = $cleaningFunction;
}
$functionObjects = [];
foreach ($cleanedFunctions as $cleanedFunction) {
$functionObject = \simplexml_load_string($cleanedFunction);
if ($functionObject) {
$functionObjects[] = $functionObject;
}
}
return $functionObjects;
}
/**
* Loads the XML file, resolving all DTD declared entities.
*
* @return \SimpleXMLElement
*/
public function loadAndResolveFile(): \SimpleXMLElement
{
$content = \file_get_contents($this->path);
$strpos = \strpos($content, '?>')+2;
if (!\file_exists(__DIR__.'/../doc/entities/generated.ent')) {
self::buildEntities();
}
$path = \realpath(__DIR__.'/../doc/entities/generated.ent');
$content = \substr($content, 0, $strpos)
.'<!DOCTYPE refentry SYSTEM "'.$path.'">'
.\substr($content, $strpos+1);
echo 'Loading '.$this->path."\n";
$elem = \simplexml_load_string($content, \SimpleXMLElement::class, LIBXML_DTDLOAD | LIBXML_NOENT);
if ($elem === false) {
throw new \RuntimeException('Invalid XML file for '.$this->path);
}
$elem->registerXPathNamespace('docbook', 'http://docbook.org/ns/docbook');
return $elem;
}
/**
* Returns the module name in Camelcase.
*
* @return string
*/
public function getModule(): string
{
return $this->toCamelCase(\basename(\dirname($this->path, 2)));
}
private function toCamelCase(string $str): string
{
$tokens = preg_split("/[_ ]+/", $str);
if ($tokens === false) {
throw new \RuntimeException('Unexpected preg_split error'); // @codeCoverageIgnore
}
$str = '';
foreach ($tokens as $token) {
$str .= ucfirst($token);
}
return $str;
}
/**
* @param mixed[] $array multidimensional string array
* @return string[]
*/
private function arrayFlatten(array $array): array
{
$result = array();
foreach ($array as $key => $value) {
if (is_array($value)) {
$result = array_merge($result, $this->arrayFlatten($value));
} else {
$result[$key] = $value;
}
}
return $result;
}
public static function buildEntities(): void
{
$file1 = \file_get_contents(__DIR__.'/../doc/doc-en/en/language-defs.ent');
$file2 = \file_get_contents(__DIR__.'/../doc/doc-en/en/language-snippets.ent');
$file3 = \file_get_contents(__DIR__.'/../doc/doc-en/en/extensions.ent');
$file4 = \file_get_contents(__DIR__.'/../doc/doc-en/doc-base/entities/global.ent');
$completeFile = $file1 . self::extractXmlHeader($file2) . self::extractXmlHeader($file3) . $file4;
\file_put_contents(__DIR__.'/../doc/entities/generated.ent', $completeFile);
}
private static function extractXmlHeader(string $content): string
{
$strpos = strpos($content, '?>')+2;
return substr($content, $strpos);
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace Safe;
use Exception;
class EmptyTypeException extends Exception
{
}

View file

@ -0,0 +1,169 @@
<?php
namespace Safe;
use function array_merge;
use Complex\Exception;
use function file_exists;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class FileCreator
{
/**
* This function generate an xls file
*
* @param string[] $protoFunctions
* @param string $path
*/
public function generateXlsFile(array $protoFunctions, string $path): void
{
$spreadsheet = new Spreadsheet();
$numb = 1;
$sheet = $spreadsheet->getActiveSheet();
$sheet->setCellValue('A1', 'Function name');
$sheet->setCellValue('B1', 'Status');
foreach ($protoFunctions as $protoFunction) {
if ($protoFunction) {
if (strpos($protoFunction, '=') === false && strpos($protoFunction, 'json') === false) {
$status = 'classic';
} elseif (strpos($protoFunction, 'json')) {
$status = 'json';
} else {
$status = 'opt';
}
$sheet->setCellValue('A'.$numb, $protoFunction);
$sheet->setCellValue('B'.$numb++, $status);
}
}
$writer = new Xlsx($spreadsheet);
$writer->save($path);
}
/**
* This function generate an improved php lib function in a php file
*
* @param Method[] $functions
* @param string $path
*/
public function generatePhpFile(array $functions, string $path): void
{
$path = rtrim($path, '/').'/';
$phpFunctionsByModule = [];
foreach ($functions as $function) {
$writePhpFunction = new WritePhpFunction($function);
$phpFunctionsByModule[$function->getModuleName()][] = $writePhpFunction->getPhpFunctionalFunction();
}
foreach ($phpFunctionsByModule as $module => $phpFunctions) {
$lcModule = \lcfirst($module);
$stream = \fopen($path.$lcModule.'.php', 'w');
if ($stream === false) {
throw new \RuntimeException('Unable to write to '.$path);
}
\fwrite($stream, "<?php\n
namespace Safe;
use Safe\\Exceptions\\".self::toExceptionName($module). ';
');
foreach ($phpFunctions as $phpFunction) {
\fwrite($stream, $phpFunction."\n");
}
\fclose($stream);
}
}
/**
* @param Method[] $functions
* @return string[]
*/
private function getFunctionsNameList(array $functions): array
{
$functionNames = array_map(function (Method $function) {
return $function->getFunctionName();
}, $functions);
$specialCases = require __DIR__.'/../config/specialCasesFunctions.php';
return array_merge($functionNames, $specialCases);
}
/**
* This function generate a PHP file containing the list of functions we can handle.
*
* @param Method[] $functions
* @param string $path
*/
public function generateFunctionsList(array $functions, string $path): void
{
$functionNames = $this->getFunctionsNameList($functions);
$stream = fopen($path, 'w');
if ($stream === false) {
throw new \RuntimeException('Unable to write to '.$path);
}
fwrite($stream, "<?php\n
return [\n");
foreach ($functionNames as $functionName) {
fwrite($stream, ' '.\var_export($functionName, true).",\n");
}
fwrite($stream, "];\n");
fclose($stream);
}
/**
* This function generate a rector yml file containing a replacer for all functions
*
* @param Method[] $functions
* @param string $path
*/
public function generateRectorFile(array $functions, string $path): void
{
$functionNames = $this->getFunctionsNameList($functions);
$stream = fopen($path, 'w');
if ($stream === false) {
throw new \RuntimeException('Unable to write to '.$path);
}
fwrite($stream, "# This rector file is replacing all core PHP functions with the equivalent \"safe\" functions
services:
Rector\Rector\Function_\FunctionReplaceRector:
\$oldFunctionToNewFunction:
");
foreach ($functionNames as $functionName) {
fwrite($stream, ' '.$functionName.": 'Safe\\".$functionName."'\n");
}
fclose($stream);
}
public function createExceptionFile(string $moduleName): void
{
$exceptionName = self::toExceptionName($moduleName);
if (!file_exists(__DIR__.'/../../lib/Exceptions/'.$exceptionName.'.php')) {
\file_put_contents(
__DIR__.'/../../generated/Exceptions/'.$exceptionName.'.php',
<<<EOF
<?php
namespace Safe\Exceptions;
class {$exceptionName} extends AbstractSafeException
{
}
EOF
);
}
}
/**
* Generates the name of the exception class
*
* @param string $moduleName
* @return string
*/
public static function toExceptionName(string $moduleName): string
{
return str_replace('-', '', \ucfirst($moduleName)).'Exception';
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace Safe;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
class GenerateCommand extends Command
{
protected function configure()
{
$this
->setName('generate')
->setDescription('Generates the PHP file with all functions.')
;
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$this->rmGenerated();
// Let's build the DTD necessary to load the XML files.
DocPage::buildEntities();
$scanner = new Scanner(__DIR__ . '/../doc/doc-en/en/reference/');
$paths = $scanner->getFunctionsPaths();
[
'functions' => $functions,
'overloadedFunctions' => $overloadedFunctions
] = $scanner->getMethods($paths);
$output->writeln('These functions have been ignored and must be dealt with manually: '.\implode(', ', $overloadedFunctions));
$fileCreator = new FileCreator();
//$fileCreator->generateXlsFile($protoFunctions, __DIR__ . '/../generated/lib.xls');
$fileCreator->generatePhpFile($functions, __DIR__ . '/../../generated/');
$fileCreator->generateFunctionsList($functions, __DIR__ . '/../../generated/functionsList.php');
$fileCreator->generateRectorFile($functions, __DIR__ . '/../../rector-migrate.yml');
$modules = [];
foreach ($functions as $function) {
$modules[$function->getModuleName()] = $function->getModuleName();
}
foreach ($modules as $moduleName => $foo) {
$fileCreator->createExceptionFile($moduleName);
}
$this->runCsFix($output);
// Let's require the generated file to check there is no error.
$files = \glob(__DIR__.'/../../generated/*.php');
foreach ($files as $file) {
require($file);
}
$files = \glob(__DIR__.'/../../generated/Exceptions/*.php');
require_once __DIR__.'/../../lib/Exceptions/SafeExceptionInterface.php';
require_once __DIR__.'/../../lib/Exceptions/AbstractSafeException.php';
foreach ($files as $file) {
require($file);
}
// Finally, let's edit the composer.json file
$output->writeln('Editing composer.json');
ComposerJsonEditor::editFiles(\array_values($modules));
}
private function rmGenerated(): void
{
$exceptions = \glob(__DIR__.'/../../generated/Exceptions/*.php');
foreach ($exceptions as $exception) {
\unlink($exception);
}
$files = \glob(__DIR__.'/../../generated/*.php');
foreach ($files as $file) {
\unlink($file);
}
if (\file_exists(__DIR__.'/../doc/entities/generated.ent')) {
\unlink(__DIR__.'/../doc/entities/generated.ent');
}
}
private function runCsFix(OutputInterface $output): void
{
$process = new Process('vendor/bin/phpcbf', __DIR__.'/../..');
$process->setTimeout(600);
$process->run(function ($type, $buffer) use ($output) {
if (Process::ERR === $type) {
echo $output->write('<error>'.$buffer.'</error>');
} else {
echo $output->write($buffer);
}
});
}
}

View file

@ -0,0 +1,233 @@
<?php
namespace Safe;
use Safe\PhpStanFunctions\PhpStanFunction;
use Safe\PhpStanFunctions\PhpStanFunctionMapReader;
class Method
{
/**
* @var \SimpleXMLElement
*/
private $functionObject;
/**
* @var \SimpleXMLElement
*/
private $rootEntity;
/**
* @var string
*/
private $moduleName;
/**
* @var Parameter[]|null
*/
private $params = null;
/**
* @var PhpStanFunctionMapReader
*/
private $phpStanFunctionMapReader;
public function __construct(\SimpleXMLElement $_functionObject, \SimpleXMLElement $rootEntity, string $moduleName, PhpStanFunctionMapReader $phpStanFunctionMapReader)
{
$this->functionObject = $_functionObject;
$this->rootEntity = $rootEntity;
$this->moduleName = $moduleName;
$this->phpStanFunctionMapReader = $phpStanFunctionMapReader;
}
public function getFunctionName(): string
{
return $this->functionObject->methodname->__toString();
}
public function getReturnType(): string
{
// If the function returns a boolean, since false is for error, true is for success.
// Let's replace this with a "void".
$type = $this->functionObject->type->__toString();
if ($type === 'bool') {
return 'void';
}
// Some types are completely weird. For instance, oci_new_collection returns a "OCI-Collection" (with a dash, yup)
if (\strpos($type, '-') !== false) {
return 'mixed';
}
return Type::toRootNamespace($type);
}
/**
* @return Parameter[]
*/
public function getParams(): array
{
if ($this->params === null) {
if (!isset($this->functionObject->methodparam)) {
return [];
}
$phpStanFunction = $this->getPhpStanData();
$params = [];
$i=1;
foreach ($this->functionObject->methodparam as $param) {
$notes = $this->stripReturnFalseText($this->getStringForXPath("(//docbook:refsect1[@role='parameters']//docbook:varlistentry)[$i]//docbook:note//docbook:para"));
$i++;
if (preg_match('/This parameter has been removed in PHP (\d+\.\d+\.\d+)/', $notes, $matches)) {
$removedVersion = $matches[1];
[$major, $minor] = explode('.', $removedVersion);
if ($major < 7 || ($major == 7 && $minor == 0)) {
// Ignore parameter if it was removed before PHP 7.1
continue;
}
}
$params[] = new Parameter($param, $phpStanFunction);
}
$this->params = $params;
}
return $this->params;
}
public function getPhpDoc(): string
{
$str = "/**\n".
implode("\n", array_map(function (string $line) {
return ' * '.ltrim($line);
}, \explode("\n", \strip_tags($this->getDocBlock()))))
."\n */\n";
return $str;
}
private function getDocBlock(): string
{
$str = $this->stripReturnFalseText($this->getStringForXPath("//docbook:refsect1[@role='description']/docbook:para"));
$str .= "\n\n";
$i=1;
foreach ($this->getParams() as $parameter) {
$str .= '@param '.$parameter->getBestType().' $'.$parameter->getParameter().' ';
$str .= $this->getStringForXPath("(//docbook:refsect1[@role='parameters']//docbook:varlistentry)[$i]//docbook:para")."\n";
$i++;
}
$bestReturnType = $this->getBestReturnType();
if ($bestReturnType !== 'void') {
$str .= '@return '.$bestReturnType. ' ' .$this->getReturnDoc()."\n";
}
$str .= '@throws '.FileCreator::toExceptionName($this->getModuleName()). "\n";
return $str;
}
private function getReturnDoc(): string
{
$returnDoc = $this->getStringForXPath("//docbook:refsect1[@role='returnvalues']/docbook:para");
return $this->stripReturnFalseText($returnDoc);
}
private function stripReturnFalseText(string $string): string
{
$string = \strip_tags($string);
$string = $this->removeString($string, 'or FALSE on failure');
$string = $this->removeString($string, 'may return FALSE');
$string = $this->removeString($string, 'and FALSE on failure');
$string = $this->removeString($string, 'on success, or FALSE otherwise');
$string = $this->removeString($string, 'or FALSE on error');
$string = $this->removeString($string, 'or FALSE if an error occurred');
$string = $this->removeString($string, 'the function will return TRUE, or FALSE otherwise');
return $string;
}
/**
* Removes a string, even if the string is split on multiple lines.
* @param string $string
* @param string $search
* @return string
*/
private function removeString(string $string, string $search): string
{
$search = str_replace(' ', '\s+', $search);
$result = preg_replace('/[\s\,]*'.$search.'/m', '', $string);
if ($result === null) {
throw new \RuntimeException('An error occurred while calling preg_replace');
}
return $result;
}
private function getStringForXPath(string $xpath): string
{
$paragraphs = $this->rootEntity->xpath($xpath);
if ($paragraphs === false) {
throw new \RuntimeException('Error while performing Xpath request.');
}
$str = '';
foreach ($paragraphs as $paragraph) {
$str .= $this->getInnerXml($paragraph)."\n\n";
}
return trim($str);
}
private function getBestReturnType(): ?string
{
$phpStanFunction = $this->getPhpStanData();
// Get the type from PhpStan database first, then from the php doc.
if ($phpStanFunction !== null) {
return Type::toRootNamespace($phpStanFunction->getReturnType());
} else {
return Type::toRootNamespace($this->getReturnType());
}
}
private function getPhpStanData(): ?PhpStanFunction
{
$functionName = $this->getFunctionName();
if (!$this->phpStanFunctionMapReader->hasFunction($functionName)) {
return null;
}
return $this->phpStanFunctionMapReader->getFunction($functionName);
}
private function getInnerXml(\SimpleXMLElement $SimpleXMLElement): string
{
$element_name = $SimpleXMLElement->getName();
$inner_xml = $SimpleXMLElement->asXML();
if ($inner_xml === false) {
throw new \RuntimeException('Unable to serialize to XML');
}
$inner_xml = str_replace(['<'.$element_name.'>', '</'.$element_name.'>'], '', $inner_xml);
$inner_xml = trim($inner_xml);
return $inner_xml;
}
public function getModuleName(): string
{
return $this->moduleName;
}
/**
* The function is overloaded if at least one parameter is optional with no default value and this parameter is not by reference.
*
* @return bool
*/
public function isOverloaded(): bool
{
foreach ($this->getParams() as $parameter) {
if ($parameter->isOptionalWithNoDefault() && !$parameter->isByReference()) {
return true;
}
}
return false;
}
public function cloneAndRemoveAParameter(): Method
{
$new = clone $this;
$params = $this->getParams();
\array_pop($params);
$new->params = $params;
return $new;
}
}

View file

@ -0,0 +1,160 @@
<?php
namespace Safe;
use Safe\PhpStanFunctions\PhpStanFunction;
class Parameter
{
/**
* @var \SimpleXMLElement
*/
private $parameter;
/**
* @var PhpStanFunction|null
*/
private $phpStanFunction;
public function __construct(\SimpleXMLElement $parameter, ?PhpStanFunction $phpStanFunction)
{
$this->parameter = $parameter;
$this->phpStanFunction = $phpStanFunction;
}
/**
* Returns the type as declared in the doc.
* @return string
*/
public function getType(): string
{
$type = $this->parameter->type->__toString();
$strType = Type::toRootNamespace($type);
if ($strType !== 'mixed' && $strType !== 'resource' && $this->phpStanFunction !== null) {
$phpStanParameter = $this->phpStanFunction->getParameter($this->getParameter());
if ($phpStanParameter) {
// Let's make the parameter nullable if it is by reference and is used only for writing.
if ($phpStanParameter->isWriteOnly()) {
$strType = '?'.$strType;
}
}
}
return $strType;
}
/**
* Returns the type as declared in the doc.
* @return string
*/
public function getBestType(): string
{
// Get the type from PhpStan database first, then from the php doc.
if ($this->phpStanFunction !== null) {
$phpStanParameter = $this->phpStanFunction->getParameter($this->getParameter());
if ($phpStanParameter) {
try {
return $phpStanParameter->getType();
} catch (EmptyTypeException $e) {
// If the type is empty in PHPStan, let's fallback to documentation.
return $this->getType();
}
}
}
return $this->getType();
}
/*
* @return string
*/
public function getParameter(): string
{
if ($this->isVariadic()) {
return 'params';
}
// The db2_bind_param method has parameters with a dash in it... yep... (patch submitted)
return \str_replace('-', '_', $this->parameter->parameter->__toString());
}
public function isByReference(): bool
{
return ((string)$this->parameter->parameter['role']) === 'reference';
}
/**
* Some parameters can be optional with no default value. In this case, the function is "overloaded" (which is not
* possible in user-land but possible in core...)
*
* @return bool
*/
public function isOptionalWithNoDefault(): bool
{
if (((string)$this->parameter['choice']) !== 'opt') {
return false;
}
if (!$this->hasDefaultValue()) {
return true;
}
$initializer = $this->getInitializer();
// Some default value have weird values. For instance, first parameter of "mb_internal_encoding" has default value "mb_internal_encoding()"
if ($initializer !== 'array()' && strpos($initializer, '(') !== false) {
return true;
}
return false;
}
public function isVariadic(): bool
{
return $this->parameter->parameter->__toString() === '...';
}
public function isNullable(): bool
{
if ($this->phpStanFunction !== null) {
$phpStanParameter = $this->phpStanFunction->getParameter($this->getParameter());
if ($phpStanParameter) {
return $phpStanParameter->isNullable();
}
}
return $this->hasDefaultValue() && $this->getDefaultValue() === 'null';
}
/*
* @return string
*/
public function getInitializer(): string
{
return \str_replace(['<constant>', '</constant>'], '', $this->getInnerXml($this->parameter->initializer));
}
public function hasDefaultValue(): bool
{
return isset($this->parameter->initializer);
}
public function getDefaultValue(): ?string
{
if (!$this->hasDefaultValue()) {
return null;
}
$initializer = $this->getInitializer();
// Some default value have weird values. For instance, first parameter of "mb_internal_encoding" has default value "mb_internal_encoding()"
if (strpos($initializer, '(') !== false) {
return null;
}
return $initializer;
}
private function getInnerXml(\SimpleXMLElement $SimpleXMLElement): string
{
$element_name = $SimpleXMLElement->getName();
$inner_xml = $SimpleXMLElement->asXML();
if ($inner_xml === false) {
throw new \RuntimeException('Unable to serialize to XML');
}
$inner_xml = str_replace(['<'.$element_name.'>', '</'.$element_name.'>', '<'.$element_name.'/>'], '', $inner_xml);
$inner_xml = trim($inner_xml);
return $inner_xml;
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace Safe\PhpStanFunctions;
class PhpStanFunction
{
/**
* @var string
*/
private $returnType;
/**
* @var PhpStanParameter[]
*/
private $parameters = [];
/**
* @param mixed[] $signature
*/
public function __construct(array $signature)
{
$this->returnType = \array_shift($signature);
foreach ($signature as $name => $type) {
$param = new PhpStanParameter($name, $type);
$this->parameters[$param->getName()] = $param;
}
}
/**
* @return string
*/
public function getReturnType(): string
{
if ($this->returnType === 'bool') {
$this->returnType = 'void';
}
return \str_replace(['|bool', '|false'], '', $this->returnType);
}
/**
* @return array<string,PhpStanParameter>
*/
public function getParameters(): array
{
return $this->parameters;
}
public function getParameter(string $name): ?PhpStanParameter
{
return $this->parameters[$name] ?? null;
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace Safe\PhpStanFunctions;
class PhpStanFunctionMapReader
{
/**
* @var array<string, array>
*/
private $functionMap;
public function __construct()
{
$this->functionMap = require __DIR__.'/../../vendor/phpstan/phpstan/src/Reflection/SignatureMap/functionMap.php';
}
public function hasFunction(string $functionName): bool
{
return isset($this->functionMap[$functionName]);
}
public function getFunction(string $functionName): PhpStanFunction
{
return new PhpStanFunction($this->functionMap[$functionName]);
}
}

View file

@ -0,0 +1,124 @@
<?php
namespace Safe\PhpStanFunctions;
use Safe\Type;
class PhpStanParameter
{
/**
* @var string
*/
private $name;
/**
* @var string
*/
private $type;
/**
* @var bool
*/
private $optional = false;
/**
* @var bool
*/
private $variadic = false;
/**
* @var bool
*/
private $byReference = false;
/**
* @var bool
*/
private $nullable = false;
/**
* Whether the parameter is "write only" (applies only to "by reference" parameters)
* @var bool
*/
private $writeOnly = false;
public function __construct(string $name, string $type)
{
if (\strpos($name, '=') !== false) {
$this->optional = true;
}
if (\strpos($name, '...') !== false) {
$this->variadic = true;
}
if (\strpos($name, '&') !== false) {
$this->byReference = true;
}
if (\strpos($name, '&w_') !== false) {
$this->writeOnly = true;
}
$name = \str_replace(['&rw_', '&w_'], '', $name);
$name = trim($name, '=.&');
$this->name = $name;
if (\strpos($type, '?') !== false) {
$type = \str_replace('?', '', $type).'|null';
$this->nullable = true;
}
$this->type = $type;
}
/**
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* @return string
*/
public function getType(): string
{
return Type::toRootNamespace($this->type);
}
/**
* @return bool
*/
public function isOptional(): bool
{
return $this->optional;
}
/**
* @return bool
*/
public function isVariadic(): bool
{
return $this->variadic;
}
/**
* @return bool
*/
public function isByReference(): bool
{
return $this->byReference;
}
/**
* Whether the parameter is "write only" (applies only to "by reference" parameters)
* @return bool
*/
public function isWriteOnly(): bool
{
return $this->writeOnly;
}
/**
* @return bool
*/
public function isNullable(): bool
{
return $this->nullable;
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace Safe;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ScanObjectsCommand extends Command
{
protected function configure()
{
$this
->setName('scan-objects')
->setDescription('Displays all methods of all objects not handled yet by Safe.')
;
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$scanner = new Scanner(__DIR__ . '/../doc/doc-en/en/reference/');
$paths = $scanner->getMethodsPaths();
[
'functions' => $functions,
'overloadedFunctions' => $overloadedFunctions
] = $scanner->getMethods($paths);
foreach ($functions as $function) {
$name = $function->getFunctionName();
$output->writeln('Found method '.$name);
}
$output->writeln('These methods are overloaded: '.\implode(', ', $overloadedFunctions));
}
}

View file

@ -0,0 +1,124 @@
<?php
namespace Safe;
use function array_merge;
use function iterator_to_array;
use Safe\PhpStanFunctions\PhpStanFunctionMapReader;
use Symfony\Component\Finder\Finder;
use SplFileInfo;
class Scanner
{
/*
* @var string
*/
private $path;
public function __construct(string $path)
{
$this->path = $path;
}
/**
* @return array<string, SplFileInfo>
*/
public function getFunctionsPaths(): array
{
$finder = new Finder();
$finder->in($this->path.'*/functions/')->name('*.xml')->sortByName();
return iterator_to_array($finder);
}
/**
* @return array<string, SplFileInfo>
*/
public function getMethodsPaths(): array
{
$finder = new Finder();
$finder->in($this->path)->notPath('functions')->name('*.xml')->sortByName();
return iterator_to_array($finder);
}
private $ignoredFunctions;
/**
* Returns the list of functions that must be ignored.
* @return string[]
*/
private function getIgnoredFunctions(): array
{
if ($this->ignoredFunctions === null) {
$ignoredFunctions = require __DIR__.'/../config/ignoredFunctions.php';
$specialCaseFunctions = require __DIR__.'/../config/specialCasesFunctions.php';
$this->ignoredFunctions = array_merge($ignoredFunctions, $specialCaseFunctions);
}
return $this->ignoredFunctions;
}
private $ignoredModules;
/**
* Returns the list of modules that must be ignored.
* @return string[]
*/
private function getIgnoredModules(): array
{
if ($this->ignoredModules === null) {
$this->ignoredModules = require __DIR__.'/../config/ignoredModules.php';
}
return $this->ignoredModules;
}
/**
* @param SplFileInfo[] $paths
* @return mixed[] Structure: ['functions'=>Method[], 'overloadedFunctions'=>string[]]
*/
public function getMethods(array $paths): array
{
$functions = [];
$overloadedFunctions = [];
$phpStanFunctionMapReader = new PhpStanFunctionMapReader();
$ignoredFunctions = $this->getIgnoredFunctions();
$ignoredFunctions = \array_combine($ignoredFunctions, $ignoredFunctions);
$ignoredModules = $this->getIgnoredModules();
$ignoredModules = \array_combine($ignoredModules, $ignoredModules);
foreach ($paths as $path) {
$module = \basename(\dirname($path, 2));
if (isset($ignoredModules[$module])) {
continue;
}
$docPage = new DocPage($path);
if ($docPage->detectFalsyFunction()) {
$functionObjects = $docPage->getMethodSynopsis();
if (count($functionObjects) > 1) {
$overloadedFunctions = array_merge($overloadedFunctions, \array_map(function ($functionObject) {
return $functionObject->methodname->__toString();
}, $functionObjects));
$overloadedFunctions = \array_filter($overloadedFunctions, function (string $functionName) use ($ignoredFunctions) {
return !isset($ignoredFunctions[$functionName]);
});
continue;
}
$rootEntity = $docPage->loadAndResolveFile();
foreach ($functionObjects as $functionObject) {
$function = new Method($functionObject, $rootEntity, $docPage->getModule(), $phpStanFunctionMapReader);
if (isset($ignoredFunctions[$function->getFunctionName()])) {
continue;
}
$functions[] = $function;
}
}
}
return [
'functions' => $functions,
'overloadedFunctions' => \array_unique($overloadedFunctions)
];
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace Safe;
class Type
{
/**
* Returns true if the type passed in parameter is a class, false if it is scalar or resource
*
* @param string $type
* @return bool
*/
private static function isClass(string $type): bool
{
if ($type === '') {
throw new EmptyTypeException('Empty type passed');
}
if ($type === 'stdClass') {
return true;
}
// Classes start with uppercase letters. Otherwise, it's most likely a scalar.
if ($type[0] === strtoupper($type[0])) {
return true;
}
return false;
}
/**
* Put classes in the root namespace
*
* @param string $type
* @return string
*/
public static function toRootNamespace(string $type): string
{
if (self::isClass($type)) {
return '\\'.$type;
}
return $type;
}
}

View file

@ -0,0 +1,190 @@
<?php
namespace Safe;
class WritePhpFunction
{
/**
* @var Method
*/
private $method;
public function __construct(Method $method)
{
$this->method = $method;
}
/*
* @return string
*/
public function getPhpPrototypeFunction(): string
{
if ($this->method->getFunctionName()) {
return 'function '.$this->method->getFunctionName().'('.$this->displayParamsWithType($this->method->getParams()).')'.': '.$this->method->getReturnType().'{}';
}
return '';
}
/*
* return string
*/
public function getPhpFunctionalFunction(): string
{
if ($this->getPhpPrototypeFunction()) {
return $this->writePhpFunction();
}
return '';
}
/*
* return string
*/
private function writePhpFunction(): string
{
$phpFunction = $this->method->getPhpDoc();
if ($this->method->getReturnType() !== 'mixed' && $this->method->getReturnType() !== 'resource') {
$returnType = ': ' . $this->method->getReturnType();
} else {
$returnType = '';
}
$returnStatement = '';
if ($this->method->getReturnType() !== 'void') {
$returnStatement = " return \$result;\n";
}
$moduleName = $this->method->getModuleName();
$phpFunction .= "function {$this->method->getFunctionName()}({$this->displayParamsWithType($this->method->getParams())}){$returnType}
{
error_clear_last();
";
if (!$this->method->isOverloaded()) {
$phpFunction .= ' $result = '.$this->printFunctionCall($this->method);
} else {
$method = $this->method;
$inElse = false;
do {
$lastParameter = $method->getParams()[count($method->getParams())-1];
if ($inElse) {
$phpFunction .= ' else';
} else {
$phpFunction .= ' ';
}
if ($lastParameter->isVariadic()) {
$defaultValueToString = '[]';
} else {
$defaultValue = $lastParameter->getDefaultValue();
$defaultValueToString = $this->defaultValueToString($defaultValue);
}
$phpFunction .= 'if ($'.$lastParameter->getParameter().' !== '.$defaultValueToString.') {'."\n";
$phpFunction .= ' $result = '.$this->printFunctionCall($method)."\n";
$phpFunction .= ' }';
$inElse = true;
$method = $method->cloneAndRemoveAParameter();
if (!$method->isOverloaded()) {
break;
}
} while (true);
$phpFunction .= 'else {'."\n";
$phpFunction .= ' $result = '.$this->printFunctionCall($method)."\n";
$phpFunction .= ' }';
}
$phpFunction .= $this->generateExceptionCode($moduleName, $this->method).$returnStatement. '}
';
return $phpFunction;
}
private function generateExceptionCode(string $moduleName, Method $method) : string
{
// Special case for CURL: we need the first argument of the method if this is a resource.
if ($moduleName === 'Curl') {
$params = $method->getParams();
if (\count($params) > 0 && $params[0]->getParameter() === 'ch') {
return "
if (\$result === false) {
throw CurlException::createFromCurlResource(\$ch);
}
";
}
}
$exceptionName = FileCreator::toExceptionName($moduleName);
return "
if (\$result === false) {
throw {$exceptionName}::createFromPhpError();
}
";
}
/**
* @param Parameter[] $params
* @return string
*/
private function displayParamsWithType(array $params): string
{
$paramsAsString = [];
$optDetected = false;
foreach ($params as $param) {
$paramAsString = '';
if ($param->getType() !== 'mixed' && $param->getType() !== 'resource') {
if ($param->isNullable()) {
$paramAsString .= '?';
}
$paramAsString .= $param->getType().' ';
}
$paramName = $param->getParameter();
if ($param->isVariadic()) {
$paramAsString .= ' ...$'.$paramName;
} else {
if ($param->isByReference()) {
$paramAsString .= '&';
}
$paramAsString .= '$'.$paramName;
}
if ($param->hasDefaultValue() || $param->isOptionalWithNoDefault()) {
$optDetected = true;
}
$defaultValue = $param->getDefaultValue();
if ($defaultValue !== null) {
$paramAsString .= ' = '.$this->defaultValueToString($defaultValue);
} elseif ($optDetected && !$param->isVariadic()) {
$paramAsString .= ' = null';
}
$paramsAsString[] = $paramAsString;
}
return implode(', ', $paramsAsString);
}
private function printFunctionCall(Method $function): string
{
$functionCall = '\\'.$function->getFunctionName().'(';
$functionCall .= implode(', ', \array_map(function (Parameter $parameter) {
$str = '';
if ($parameter->isVariadic()) {
$str = '...';
}
return $str.'$'.$parameter->getParameter();
}, $function->getParams()));
$functionCall .= ');';
return $functionCall;
}
private function defaultValueToString(?string $defaultValue): string
{
if ($defaultValue === null) {
return 'null';
}
if ($defaultValue === '') {
return "''";
}
return $defaultValue;
}
}