-
-
Notifications
You must be signed in to change notification settings - Fork 75
Setting expose-global-functions to false breaks internal function calls #960
Description
Bug report
| Question | Answer |
|---|---|
| PHP-Scoper version | ^0.18.11 |
| PHP version | 8.2.6 |
| Platform with version | MacOS |
| Github Repo | Minimal example repository |
Thanks for building PHP Scoper. It's been a huge help over the last two years.
I'm running into some unexpected behavior when using PHP Scoper with illuminate/support and other similar packages. After scoping with expose-global-functions set to false, I'm getting various errors about undefined functions.
To set the stage, illuminate/support comes with helpers.php which contains some global function definitions. This are used in a few different places, but here's an example where get_data is used on line 50 of Fluent.php.
In the example repo, I then make use of Fluent as seen below. Keep in mind this was just to demo the issue. The actual issue in the project I'm working on comes up after using illuminate/database to build some queries.
$f = new \Illuminate\Support\Fluent([
'name' => 'Andrew',
'is_verified' => true
]);
var_dump($f->get('name')); // "Andrew"The code above actually works just fine with PHP Scoper, but only with expose-global-function set to true. If I set it to false, I get the following error:
PHP Fatal error: Uncaught Error: Call to undefined function DEMO\Illuminate\Support\data_get() in /Users/andrewmead/Projects/scoper/build/vendor/illuminate/support/Fluent.php:47
Stack trace:
#0 /Users/andrewmead/Projects/scoper/build/index.php(8): DEMO\Illuminate\Support\Fluent->get('name')
#1 {main}
thrown in /Users/andrewmead/Projects/scoper/build/vendor/illuminate/support/Fluent.php on line 47
Looking at the scoped code, I saw that things were not lining up. In helpers.php the scope was added correctly (expandable below). In Fluent.php it was still calling data_get when it really needs to call \DEMO\data_get.
helpers.php after scoping
<?php
namespace DEMO;
use DEMO\Illuminate\Contracts\Support\DeferringDisplayableValue;
use DEMO\Illuminate\Contracts\Support\Htmlable;
use DEMO\Illuminate\Support\Arr;
use DEMO\Illuminate\Support\Env;
use DEMO\Illuminate\Support\HigherOrderTapProxy;
use DEMO\Illuminate\Support\Once;
use DEMO\Illuminate\Support\Onceable;
use DEMO\Illuminate\Support\Optional;
use DEMO\Illuminate\Support\Sleep;
use DEMO\Illuminate\Support\Str;
if (!\function_exists('DEMO\\append_config')) {
/**
* Assign high numeric IDs to a config item to force appending.
*
* @param array $array
* @return array
*/
function append_config(array $array)
{
$start = 9999;
foreach ($array as $key => $value) {
if (\is_numeric($key)) {
$start++;
$array[$start] = Arr::pull($array, $key);
}
}
return $array;
}
}
if (!\function_exists('DEMO\\blank')) {
/**
* Determine if the given value is "blank".
*
* @param mixed $value
* @return bool
*/
function blank($value)
{
if (\is_null($value)) {
return \true;
}
if (\is_string($value)) {
return \trim($value) === '';
}
if (\is_numeric($value) || \is_bool($value)) {
return \false;
}
if ($value instanceof \Countable) {
return \count($value) === 0;
}
return empty($value);
}
}
if (!\function_exists('DEMO\\class_basename')) {
/**
* Get the class "basename" of the given object / class.
*
* @param string|object $class
* @return string
*/
function class_basename($class)
{
$class = \is_object($class) ? \get_class($class) : $class;
return \basename(\str_replace('\\', '/', $class));
}
}
if (!\function_exists('DEMO\\class_uses_recursive')) {
/**
* Returns all traits used by a class, its parent classes and trait of their traits.
*
* @param object|string $class
* @return array
*/
function class_uses_recursive($class)
{
if (\is_object($class)) {
$class = \get_class($class);
}
$results = [];
foreach (\array_reverse(\class_parents($class) ?: []) + [$class => $class] as $class) {
$results += trait_uses_recursive($class);
}
return \array_unique($results);
}
}
if (!\function_exists('DEMO\\e')) {
/**
* Encode HTML special characters in a string.
*
* @param \Illuminate\Contracts\Support\DeferringDisplayableValue|\Illuminate\Contracts\Support\Htmlable|\BackedEnum|string|null $value
* @param bool $doubleEncode
* @return string
*/
function e($value, $doubleEncode = \true)
{
if ($value instanceof DeferringDisplayableValue) {
$value = $value->resolveDisplayableValue();
}
if ($value instanceof Htmlable) {
return $value->toHtml();
}
if ($value instanceof \BackedEnum) {
$value = $value->value;
}
return \htmlspecialchars($value ?? '', \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8', $doubleEncode);
}
}
if (!\function_exists('DEMO\\env')) {
/**
* Gets the value of an environment variable.
*
* @param string $key
* @param mixed $default
* @return mixed
*/
function env($key, $default = null)
{
return Env::get($key, $default);
}
}
if (!\function_exists('DEMO\\filled')) {
/**
* Determine if a value is "filled".
*
* @param mixed $value
* @return bool
*/
function filled($value)
{
return !blank($value);
}
}
if (!\function_exists('DEMO\\literal')) {
/**
* Return a new literal or anonymous object using named arguments.
*
* @return \stdClass
*/
function literal(...$arguments)
{
if (\count($arguments) === 1 && \array_is_list($arguments)) {
return $arguments[0];
}
return (object) $arguments;
}
}
if (!\function_exists('DEMO\\object_get')) {
/**
* Get an item from an object using "dot" notation.
*
* @param object $object
* @param string|null $key
* @param mixed $default
* @return mixed
*/
function object_get($object, $key, $default = null)
{
if (\is_null($key) || \trim($key) === '') {
return $object;
}
foreach (\explode('.', $key) as $segment) {
if (!\is_object($object) || !isset($object->{$segment})) {
return value($default);
}
$object = $object->{$segment};
}
return $object;
}
}
if (!\function_exists('DEMO\\once')) {
/**
* Ensures a callable is only called once, and returns the result on subsequent calls.
*
* @template TReturnType
*
* @param callable(): TReturnType $callback
* @return TReturnType
*/
function once(callable $callback)
{
$onceable = Onceable::tryFromTrace(\debug_backtrace(\DEBUG_BACKTRACE_PROVIDE_OBJECT, 2), $callback);
return $onceable ? Once::instance()->value($onceable) : \call_user_func($callback);
}
}
if (!\function_exists('DEMO\\optional')) {
/**
* Provide access to optional objects.
*
* @param mixed $value
* @param callable|null $callback
* @return mixed
*/
function optional($value = null, callable $callback = null)
{
if (\is_null($callback)) {
return new Optional($value);
} elseif (!\is_null($value)) {
return $callback($value);
}
}
}
if (!\function_exists('DEMO\\preg_replace_array')) {
/**
* Replace a given pattern with each value in the array in sequentially.
*
* @param string $pattern
* @param array $replacements
* @param string $subject
* @return string
*/
function preg_replace_array($pattern, array $replacements, $subject)
{
return \preg_replace_callback($pattern, function () use(&$replacements) {
foreach ($replacements as $value) {
return \array_shift($replacements);
}
}, $subject);
}
}
if (!\function_exists('DEMO\\retry')) {
/**
* Retry an operation a given number of times.
*
* @param int|array $times
* @param callable $callback
* @param int|\Closure $sleepMilliseconds
* @param callable|null $when
* @return mixed
*
* @throws \Exception
*/
function retry($times, callable $callback, $sleepMilliseconds = 0, $when = null)
{
$attempts = 0;
$backoff = [];
if (\is_array($times)) {
$backoff = $times;
$times = \count($times) + 1;
}
beginning:
$attempts++;
$times--;
try {
return $callback($attempts);
} catch (\Exception $e) {
if ($times < 1 || $when && !$when($e)) {
throw $e;
}
$sleepMilliseconds = $backoff[$attempts - 1] ?? $sleepMilliseconds;
if ($sleepMilliseconds) {
Sleep::usleep(value($sleepMilliseconds, $attempts, $e) * 1000);
}
goto beginning;
}
}
}
if (!\function_exists('DEMO\\str')) {
/**
* Get a new stringable object from the given string.
*
* @param string|null $string
* @return \Illuminate\Support\Stringable|mixed
*/
function str($string = null)
{
if (\func_num_args() === 0) {
return new class
{
public function __call($method, $parameters)
{
return Str::$method(...$parameters);
}
public function __toString()
{
return '';
}
};
}
return Str::of($string);
}
}
if (!\function_exists('DEMO\\tap')) {
/**
* Call the given Closure with the given value then return the value.
*
* @param mixed $value
* @param callable|null $callback
* @return mixed
*/
function tap($value, $callback = null)
{
if (\is_null($callback)) {
return new HigherOrderTapProxy($value);
}
$callback($value);
return $value;
}
}
if (!\function_exists('DEMO\\throw_if')) {
/**
* Throw the given exception if the given condition is true.
*
* @template TException of \Throwable
*
* @param mixed $condition
* @param TException|class-string<TException>|string $exception
* @param mixed ...$parameters
* @return mixed
*
* @throws TException
*/
function throw_if($condition, $exception = 'RuntimeException', ...$parameters)
{
if ($condition) {
if (\is_string($exception) && \class_exists($exception)) {
$exception = new $exception(...$parameters);
}
throw \is_string($exception) ? new \RuntimeException($exception) : $exception;
}
return $condition;
}
}
if (!\function_exists('DEMO\\throw_unless')) {
/**
* Throw the given exception unless the given condition is true.
*
* @template TException of \Throwable
*
* @param mixed $condition
* @param TException|class-string<TException>|string $exception
* @param mixed ...$parameters
* @return mixed
*
* @throws TException
*/
function throw_unless($condition, $exception = 'RuntimeException', ...$parameters)
{
throw_if(!$condition, $exception, ...$parameters);
return $condition;
}
}
if (!\function_exists('DEMO\\trait_uses_recursive')) {
/**
* Returns all traits used by a trait and its traits.
*
* @param object|string $trait
* @return array
*/
function trait_uses_recursive($trait)
{
$traits = \class_uses($trait) ?: [];
foreach ($traits as $trait) {
$traits += trait_uses_recursive($trait);
}
return $traits;
}
}
if (!\function_exists('DEMO\\transform')) {
/**
* Transform the given value if it is present.
*
* @template TValue of mixed
* @template TReturn of mixed
* @template TDefault of mixed
*
* @param TValue $value
* @param callable(TValue): TReturn $callback
* @param TDefault|callable(TValue): TDefault|null $default
* @return ($value is empty ? ($default is null ? null : TDefault) : TReturn)
*/
function transform($value, callable $callback, $default = null)
{
if (filled($value)) {
return $callback($value);
}
if (\is_callable($default)) {
return $default($value);
}
return $default;
}
}
if (!\function_exists('DEMO\\windows_os')) {
/**
* Determine whether the current environment is Windows based.
*
* @return bool
*/
function windows_os()
{
return \PHP_OS_FAMILY === 'Windows';
}
}
if (!\function_exists('DEMO\\with')) {
/**
* Return the given value, optionally passed through the given callback.
*
* @template TValue
* @template TReturn
*
* @param TValue $value
* @param (callable(TValue): (TReturn))|null $callback
* @return ($callback is null ? TValue : TReturn)
*/
function with($value, callable $callback = null)
{
return \is_null($callback) ? $value : $callback($value);
}
}Fluent.php after scoping
<?php
namespace DEMO\Illuminate\Support;
use ArrayAccess;
use DEMO\Illuminate\Contracts\Support\Arrayable;
use DEMO\Illuminate\Contracts\Support\Jsonable;
use JsonSerializable;
/**
* @template TKey of array-key
* @template TValue
*
* @implements \Illuminate\Contracts\Support\Arrayable<TKey, TValue>
* @implements \ArrayAccess<TKey, TValue>
*/
class Fluent implements Arrayable, ArrayAccess, Jsonable, JsonSerializable
{
/**
* All of the attributes set on the fluent instance.
*
* @var array<TKey, TValue>
*/
protected $attributes = [];
/**
* Create a new fluent instance.
*
* @param iterable<TKey, TValue> $attributes
* @return void
*/
public function __construct($attributes = [])
{
foreach ($attributes as $key => $value) {
$this->attributes[$key] = $value;
}
}
/**
* Get an attribute from the fluent instance using "dot" notation.
*
* @template TGetDefault
*
* @param TKey $key
* @param TGetDefault|(\Closure(): TGetDefault) $default
* @return TValue|TGetDefault
*/
public function get($key, $default = null)
{
return data_get($this->attributes, $key, $default);
}
/**
* Get an attribute from the fluent instance.
*
* @param string $key
* @param mixed $default
* @return mixed
*/
public function value($key, $default = null)
{
if (\array_key_exists($key, $this->attributes)) {
return $this->attributes[$key];
}
return value($default);
}
/**
* Get the value of the given key as a new Fluent instance.
*
* @param string $key
* @param mixed $default
* @return static
*/
public function scope($key, $default = null)
{
return new static((array) $this->get($key, $default));
}
/**
* Get the attributes from the fluent instance.
*
* @return array<TKey, TValue>
*/
public function getAttributes()
{
return $this->attributes;
}
/**
* Convert the fluent instance to an array.
*
* @return array<TKey, TValue>
*/
public function toArray()
{
return $this->attributes;
}
/**
* Convert the fluent instance to a Collection.
*
* @param string|null $key
* @return \Illuminate\Support\Collection
*/
public function collect($key = null)
{
return new Collection($this->get($key));
}
/**
* Convert the object into something JSON serializable.
*
* @return array<TKey, TValue>
*/
public function jsonSerialize() : array
{
return $this->toArray();
}
/**
* Convert the fluent instance to JSON.
*
* @param int $options
* @return string
*/
public function toJson($options = 0)
{
return \json_encode($this->jsonSerialize(), $options);
}
/**
* Determine if the given offset exists.
*
* @param TKey $offset
* @return bool
*/
public function offsetExists($offset) : bool
{
return isset($this->attributes[$offset]);
}
/**
* Get the value for a given offset.
*
* @param TKey $offset
* @return TValue|null
*/
public function offsetGet($offset) : mixed
{
return $this->value($offset);
}
/**
* Set the value at the given offset.
*
* @param TKey $offset
* @param TValue $value
* @return void
*/
public function offsetSet($offset, $value) : void
{
$this->attributes[$offset] = $value;
}
/**
* Unset the value at the given offset.
*
* @param TKey $offset
* @return void
*/
public function offsetUnset($offset) : void
{
unset($this->attributes[$offset]);
}
/**
* Handle dynamic calls to the fluent instance to set attributes.
*
* @param TKey $method
* @param array{0: ?TValue} $parameters
* @return $this
*/
public function __call($method, $parameters)
{
$this->attributes[$method] = \count($parameters) > 0 ? \reset($parameters) : \true;
return $this;
}
/**
* Dynamically retrieve the value of an attribute.
*
* @param TKey $key
* @return TValue|null
*/
public function __get($key)
{
return $this->value($key);
}
/**
* Dynamically set the value of an attribute.
*
* @param TKey $key
* @param TValue $value
* @return void
*/
public function __set($key, $value)
{
$this->offsetSet($key, $value);
}
/**
* Dynamically check if an attribute is set.
*
* @param TKey $key
* @return bool
*/
public function __isset($key)
{
return $this->offsetExists($key);
}
/**
* Dynamically unset an attribute.
*
* @param TKey $key
* @return void
*/
public function __unset($key)
{
$this->offsetUnset($key);
}
}Is this the expected behavior here? It's worth noting that if I manually change the calls in scoped code, it works perfectly fine. It's just not adding the scope to those global function calls when scoping them.
Thanks for taking the time to look into this one. I hope I provided enough details about the issue.
Here's a zip of the build after scoping the project with expose-global-functions set to false:
scoper.inc.php
<?php
declare(strict_types=1);
use Isolated\Symfony\Component\Finder\Finder;
// You can do your own things here, e.g. collecting symbols to expose dynamically
// or files to exclude.
// However beware that this file is executed by PHP-Scoper, hence if you are using
// the PHAR it will be loaded by the PHAR. So it is highly recommended to avoid
// to auto-load any code here: it can result in a conflict or even corrupt
// the PHP-Scoper analysis.
// Example of collecting files to include in the scoped build but to not scope
// leveraging the isolated finder.
$excludedFiles = array_map(
static fn (SplFileInfo $fileInfo) => $fileInfo->getPathName(),
iterator_to_array(
Finder::create()->files()->in(__DIR__),
false,
),
);
return [
// The prefix configuration. If a non-null value is used, a random prefix
// will be generated instead.
//
// For more see: https://github.com/humbug/php-scoper/blob/master/docs/configuration.md#prefix
'prefix' => 'DEMO',
// The base output directory for the prefixed files.
// This will be overridden by the 'output-dir' command line option if present.
'output-dir' => null,
// By default when running php-scoper add-prefix, it will prefix all relevant code found in the current working
// directory. You can however define which files should be scoped by defining a collection of Finders in the
// following configuration key.
//
// This configuration entry is completely ignored when using Box.
//
// For more see: https://github.com/humbug/php-scoper/blob/master/docs/configuration.md#finders-and-paths
'finders' => [
Finder::create()
->files()
->ignoreVCS(true)
->notName('/LICENSE|.*\\.md|.*\\.dist|Makefile|composer\\.json|composer\\.lock/')
->exclude([
'doc',
'test',
'test_old',
'tests',
'Tests',
'vendor-bin',
])
->in('vendor'),
Finder::create()->append([
'composer.json',
'index.php'
]),
],
// List of excluded files, i.e. files for which the content will be left untouched.
// Paths are relative to the configuration file unless if they are already absolute
//
// For more see: https://github.com/humbug/php-scoper/blob/master/docs/configuration.md#patchers
'exclude-files' => [
// 'src/an-excluded-file.php',
// ...$excludedFiles,
],
// When scoping PHP files, there will be scenarios where some of the code being scoped indirectly references the
// original namespace. These will include, for example, strings or string manipulations. PHP-Scoper has limited
// support for prefixing such strings. To circumvent that, you can define patchers to manipulate the file to your
// heart contents.
//
// For more see: https://github.com/humbug/php-scoper/blob/master/docs/configuration.md#patchers
'patchers' => [
static function (string $filePath, string $prefix, string $contents): string {
// Change the contents here.
return $contents;
},
],
// List of symbols to consider internal i.e. to leave untouched.
//
// For more information see: https://github.com/humbug/php-scoper/blob/master/docs/configuration.md#excluded-symbols
'exclude-namespaces' => [
// 'Acme\Foo' // The Acme\Foo namespace (and sub-namespaces)
// '~^PHPUnit\\\\Framework$~', // The whole namespace PHPUnit\Framework (but not sub-namespaces)
// '~^$~', // The root namespace only
// '', // Any namespace
],
'exclude-classes' => [
// 'ReflectionClassConstant',
],
'exclude-functions' => [
// 'mb_str_split',
],
'exclude-constants' => [
// 'STDIN',
],
// List of symbols to expose.
//
// For more information see: https://github.com/humbug/php-scoper/blob/master/docs/configuration.md#exposed-symbols
'expose-global-constants' => true,
'expose-global-classes' => true,
'expose-global-functions' => true,
'expose-namespaces' => [
// 'Acme\Foo' // The Acme\Foo namespace (and sub-namespaces)
// '~^PHPUnit\\\\Framework$~', // The whole namespace PHPUnit\Framework (but not sub-namespaces)
// '~^$~', // The root namespace only
// '', // Any namespace
],
'expose-classes' => [],
'expose-functions' => [],
'expose-constants' => [],
];Output
$ vendor/bin/php-scoper add-prefix --force && composer dump-autoload --working-dir build --classmap-authoritative
____ __ ______ _____
/ __ \/ / / / __ \ / ___/_________ ____ ___ _____
/ /_/ / /_/ / /_/ / \__ \/ ___/ __ \/ __ \/ _ \/ ___/
/ ____/ __ / ____/ ___/ / /__/ /_/ / /_/ / __/ /
/_/ /_/ /_/_/ /____/\___/\____/ .___/\___/_/
/_/
PhpScoper version 0.18.11@d4df72a
1846/1846 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
[OK] Successfully prefixed 1846 files.
// Memory usage: 23.82MB (peak: 34.59MB), time: 4.05s
Generating optimized autoload files (authoritative)
Generated optimized autoload files (authoritative) containing 746 classes