diff --git a/phpunit/code/compilation_statistics.php b/phpunit/code/compilation_statistics.php new file mode 100644 index 00000000..e3c943c4 --- /dev/null +++ b/phpunit/code/compilation_statistics.php @@ -0,0 +1,7 @@ + time()]); + return $value === false ? '' : $value; +} diff --git a/phpunit/src/CompilationStatisticsTest.php b/phpunit/src/CompilationStatisticsTest.php new file mode 100644 index 00000000..77f9fc6d --- /dev/null +++ b/phpunit/src/CompilationStatisticsTest.php @@ -0,0 +1,202 @@ +addFiles([$file]); + $compiler->prepareFile($file); + $compiler->convertFile($file); + + $statistics = $compiler->getCompilationStatistics(); + self::assertTrue($statistics->has(CompilationStatistics::FUNCTIONS, 'json_encode')); + self::assertTrue($statistics->has(CompilationStatistics::FUNCTIONS, 'time')); + self::assertTrue($statistics->has(CompilationStatistics::DIRECT_FUNCTIONS, 'json_encode')); + self::assertTrue($statistics->has(CompilationStatistics::DIRECT_FUNCTIONS, 'time')); + self::assertTrue($statistics->has(CompilationStatistics::TYPES, Type::STR)); + + $selection = (new NanoExtensionSelector())->select($statistics, ['date', 'json']); + self::assertSame(['date', 'json'], $selection->extensions); + self::assertSame([], $selection->features); + } + + public function testStatisticsAreReusableByCategory(): void + { + $statistics = new CompilationStatistics(); + $statistics->begin(); + $statistics->record(CompilationStatistics::FUNCTIONS, 'json_encode'); + $statistics->record(CompilationStatistics::FUNCTIONS, 'json_encode'); + $statistics->record('future-module-data', 'example'); + $statistics->finish(); + + self::assertSame(['json_encode' => 2], $statistics->get(CompilationStatistics::FUNCTIONS)); + self::assertSame(['example' => 1], $statistics->get('future-module-data')); + } + + public function testNanoSelectorUsesFunctionsClassesTypesAndDependencies(): void + { + $statistics = new CompilationStatistics(); + $statistics->begin(); + $statistics->record(CompilationStatistics::FUNCTIONS, 'preg_match'); + $statistics->record(CompilationStatistics::RUNTIME_FUNCTIONS, 'preg_match'); + $statistics->record(CompilationStatistics::CLASSES, 'ArrayObject'); + $statistics->record(CompilationStatistics::TYPES, Type::BIGINT); + $statistics->finish(); + + $selection = (new NanoExtensionSelector())->select( + $statistics, + ['standard', 'date', 'hash', 'json', 'pcre', 'random', 'reflection', 'spl', 'filter', 'bcmath'], + ); + + self::assertFalse($selection->completeFallback); + self::assertSame(['bcmath', 'json', 'pcre', 'spl'], $selection->extensions); + self::assertSame([], $selection->features); + } + + public function testDynamicFunctionCallFallsBackToAllExtensions(): void + { + $statistics = new CompilationStatistics(); + $statistics->begin(); + $statistics->record(CompilationStatistics::DYNAMIC_CAPABILITIES, 'function-call'); + $statistics->finish(); + + $selection = (new NanoExtensionSelector())->select($statistics, ['json', 'standard']); + + self::assertTrue($selection->completeFallback); + self::assertSame(['json', 'standard'], $selection->extensions); + } + + public function testDateRemainsWholeWhileStandardUsesFineGrainedFeatures(): void + { + $statistics = new CompilationStatistics(); + $statistics->begin(); + foreach (['date', 'gmdate', 'array_keys', 'trim'] as $function) { + $statistics->record(CompilationStatistics::FUNCTIONS, $function); + $statistics->record(CompilationStatistics::DIRECT_FUNCTIONS, $function); + } + $statistics->finish(); + + $selection = (new NanoExtensionSelector())->select($statistics, ['date', 'standard']); + + self::assertSame(['date'], $selection->extensions); + self::assertSame( + ['standard.array', 'standard.string'], + $selection->features, + ); + } + + public function testZendDispatchedDateFunctionSelectsTheFullExtension(): void + { + $statistics = new CompilationStatistics(); + $statistics->begin(); + $statistics->record(CompilationStatistics::FUNCTIONS, 'date_create'); + $statistics->record(CompilationStatistics::RUNTIME_FUNCTIONS, 'date_create'); + $statistics->finish(); + + $selection = (new NanoExtensionSelector())->select($statistics, ['date']); + + self::assertSame(['date'], $selection->extensions); + self::assertSame([], $selection->features); + + $composition = (new NanoSourceComposer())->compose( + sys_get_temp_dir() . '/typephp-nano-date-component-test', + 'date_component_test', + false, + $statistics, + ); + self::assertTrue($this->containsSource($composition['packageSources'], 'ext/date/php_date.c')); + self::assertTrue($this->containsSource($composition['packageSources'], 'ext/date/lib/timelib.c')); + self::assertTrue($this->containsSource($composition['packageSources'], 'src/std/datetime.cc')); + self::assertStringContainsString('date_module_entry', file_get_contents($composition['registry'])); + } + + public function testDirectWrapperStillSelectsItsProvidingExtension(): void + { + $statistics = new CompilationStatistics(); + $statistics->begin(); + $statistics->record(CompilationStatistics::FUNCTIONS, 'json_encode'); + $statistics->record(CompilationStatistics::DIRECT_FUNCTIONS, 'json_encode'); + $statistics->finish(); + + $selection = (new NanoExtensionSelector())->select($statistics, ['json']); + + self::assertSame(['json'], $selection->extensions); + self::assertSame([], $selection->features); + } + + public function testNanoComposerAppliesStandardComponentSourcesAndDefines(): void + { + $statistics = new CompilationStatistics(); + $statistics->begin(); + $statistics->record(CompilationStatistics::FUNCTIONS, 'array_keys'); + $statistics->record(CompilationStatistics::DIRECT_FUNCTIONS, 'array_keys'); + $statistics->finish(); + + $composition = (new NanoSourceComposer())->compose( + sys_get_temp_dir() . '/typephp-nano-component-test', + 'component_test', + false, + $statistics, + ); + + self::assertContains('PHP_NANO_SELECTIVE=1', $composition['defines']); + self::assertContains('PHP_NANO_STANDARD_ARRAY=1', $composition['defines']); + self::assertContains('PHP_NANO_STANDARD_CORE=1', $composition['defines']); + self::assertTrue($this->containsSource($composition['packageSources'], 'ext/standard/array.c')); + self::assertTrue($this->containsSource($composition['packageSources'], 'ext/standard/math.c')); + self::assertTrue($this->containsSource($composition['packageSources'], 'ext/standard/string.c')); + self::assertTrue($this->containsSource($composition['packageSources'], 'ext/standard/html.c')); + self::assertFalse($this->containsSource($composition['packageSources'], 'ext/standard/base64.c')); + self::assertFalse($this->containsSource($composition['packageSources'], 'ext/date/php_date.c')); + $registry = file_get_contents($composition['registry']); + self::assertStringContainsString('basic_functions_module', $registry); + self::assertStringContainsString('random_module_entry', $registry); + self::assertStringNotContainsString('date_module_entry', $registry); + } + + public function testNanoComposerAlwaysIncludesSharedStandardSources(): void + { + $statistics = new CompilationStatistics(); + $statistics->begin(); + $statistics->record(CompilationStatistics::FUNCTIONS, 'json_encode'); + $statistics->record(CompilationStatistics::DIRECT_FUNCTIONS, 'json_encode'); + $statistics->finish(); + + $composition = (new NanoSourceComposer())->compose( + sys_get_temp_dir() . '/typephp-nano-shared-standard-test', + 'shared_standard_test', + false, + $statistics, + ); + + self::assertTrue($this->containsSource($composition['packageSources'], 'ext/standard/array.c')); + self::assertTrue($this->containsSource($composition['packageSources'], 'ext/standard/string.c')); + self::assertTrue($this->containsSource($composition['packageSources'], 'ext/standard/html.c')); + self::assertTrue($this->containsSource($composition['packageSources'], 'ext/standard/url.c')); + self::assertTrue($this->containsSource($composition['packageSources'], 'ext/standard/scanf.c')); + self::assertNotContains('PHP_NANO_STANDARD_ARRAY=1', $composition['defines']); + self::assertNotContains('PHP_NANO_STANDARD_STRING=1', $composition['defines']); + self::assertNotContains('PHP_NANO_STANDARD_ENCODING=1', $composition['defines']); + } + + /** @param list $sources */ + private function containsSource(array $sources, string $suffix): bool + { + foreach ($sources as $source) { + if (str_ends_with(str_replace('\\', '/', $source), $suffix)) { + return true; + } + } + return false; + } +} diff --git a/src/Analysis/CompilationStatistics.php b/src/Analysis/CompilationStatistics.php new file mode 100644 index 00000000..fec5d05e --- /dev/null +++ b/src/Analysis/CompilationStatistics.php @@ -0,0 +1,73 @@ +> */ + private array $counters = []; + + private bool $collecting = false; + + public function begin(): void + { + $this->counters = []; + $this->collecting = true; + } + + public function finish(): void + { + $this->collecting = false; + } + + public function isCollecting(): bool + { + return $this->collecting; + } + + public function record(string $category, string $name): void + { + if (!$this->collecting || $category === '' || $name === '') { + return; + } + $this->counters[$category][$name] = ($this->counters[$category][$name] ?? 0) + 1; + } + + /** @return array */ + public function get(string $category): array + { + $values = $this->counters[$category] ?? []; + ksort($values, SORT_STRING); + return $values; + } + + public function has(string $category, string $name): bool + { + return isset($this->counters[$category][$name]); + } + + /** @return array> */ + public function all(): array + { + $result = $this->counters; + ksort($result, SORT_STRING); + foreach ($result as &$values) { + ksort($values, SORT_STRING); + } + unset($values); + return $result; + } +} diff --git a/src/Build/ComposerNativeComponent.php b/src/Build/ComposerNativeComponent.php new file mode 100644 index 00000000..60807e6e --- /dev/null +++ b/src/Build/ComposerNativeComponent.php @@ -0,0 +1,22 @@ + $sources + * @param list $defines + * @param list $requires + */ + public function __construct( + public string $name, + public string $extension, + public array $sources, + public array $defines, + public array $requires, + public ?string $moduleEntry, + ) { + } +} diff --git a/src/Build/ComposerNativePackage.php b/src/Build/ComposerNativePackage.php index 7b863910..d2c2e8a8 100644 --- a/src/Build/ComposerNativePackage.php +++ b/src/Build/ComposerNativePackage.php @@ -11,6 +11,7 @@ final readonly class ComposerNativePackage /** * @param list $includeDirs * @param list $sources + * @param array $components */ private function __construct( public string $name, @@ -21,6 +22,7 @@ final readonly class ComposerNativePackage public int $cxxStandard, public array $includeDirs, public array $sources, + public array $components, public ?string $extensionName, public ?string $extensionModuleEntry, ) { @@ -150,6 +152,65 @@ final readonly class ComposerNativePackage } } + $components = []; + foreach (($native['components'] ?? []) as $componentName => $component) { + if (!is_string($componentName) + || preg_match('/^[a-z][a-z0-9_.-]*$/', $componentName) !== 1 + || !is_array($component)) { + throw new RuntimeException("Invalid native component metadata in `{$package}`"); + } + $componentExtension = $component['extension'] ?? strstr($componentName, '.', true); + if ($componentExtension === false) { + $componentExtension = $componentName; + } + if (!is_string($componentExtension) + || preg_match('/^[a-z][a-z0-9_]*$/', $componentExtension) !== 1) { + throw new RuntimeException("Invalid extension name for component `{$componentName}`"); + } + $componentSourceEntries = $component['sources'] ?? []; + if (!is_array($componentSourceEntries)) { + throw new RuntimeException( + "Invalid source list in component `{$package}:{$componentName}`" + ); + } + $componentSources = $componentSourceEntries === [] + ? [] + : self::resolveEntries( + $root, + $componentSourceEntries, + false, + "{$package}:{$componentName}", + ); + foreach ($componentSources as $source) { + if (!in_array($source, $sources, true)) { + throw new RuntimeException( + "Component `{$package}:{$componentName}` owns a source not published by the package" + ); + } + } + $defines = self::stringList($component['defines'] ?? [], 'define', $package, $componentName); + foreach ($defines as $define) { + if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*(?:=.*)?$/', $define) !== 1) { + throw new RuntimeException("Invalid define in component `{$package}:{$componentName}`"); + } + } + $requires = self::stringList($component['requires'] ?? [], 'requirement', $package, $componentName); + $moduleEntry = $component['module-entry'] ?? null; + if ($moduleEntry !== null + && (!is_string($moduleEntry) + || preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $moduleEntry) !== 1)) { + throw new RuntimeException("Invalid module entry in component `{$package}:{$componentName}`"); + } + $components[$componentName] = new ComposerNativeComponent( + $componentName, + $componentExtension, + $componentSources, + $defines, + $requires, + $moduleEntry, + ); + } + return new self( $package, $root, @@ -159,11 +220,30 @@ final readonly class ComposerNativePackage $cxxStandard, self::resolveEntries($root, $native['include-dirs'] ?? null, true, $package), $sources, + $components, $extensionName, $extensionModuleEntry, ); } + /** @return list */ + private static function stringList( + mixed $values, + string $label, + string $package, + string $component, + ): array { + if (!is_array($values)) { + throw new RuntimeException("Invalid {$label} list in component `{$package}:{$component}`"); + } + foreach ($values as $value) { + if (!is_string($value) || $value === '') { + throw new RuntimeException("Invalid {$label} in component `{$package}:{$component}`"); + } + } + return array_values(array_unique($values)); + } + /** * Composer installs all three core packages as siblings below vendor/swoole. * The same layout is used by the monorepo checkout, where a package may not diff --git a/src/Build/NanoExtensionSelection.php b/src/Build/NanoExtensionSelection.php new file mode 100644 index 00000000..bd48de13 --- /dev/null +++ b/src/Build/NanoExtensionSelection.php @@ -0,0 +1,20 @@ + $extensions + * @param list $features Fine-grained capabilities within large extensions. + * @param list $reasons + */ + public function __construct( + public array $extensions, + public bool $completeFallback, + public array $features = [], + public array $reasons = [], + ) { + } +} diff --git a/src/Build/NanoExtensionSelector.php b/src/Build/NanoExtensionSelector.php new file mode 100644 index 00000000..057a2475 --- /dev/null +++ b/src/Build/NanoExtensionSelector.php @@ -0,0 +1,401 @@ + */ + private const array EXTENSION_NAMES = [ + 'standard' => 'standard', + 'date' => 'date', + 'hash' => 'hash', + 'json' => 'json', + 'pcre' => 'pcre', + 'random' => 'random', + 'reflection' => 'reflection', + 'spl' => 'spl', + 'filter' => 'filter', + 'bcmath' => 'bcmath', + ]; + + /** @var array> */ + private const array DEPENDENCIES = [ + 'spl' => ['json'], + ]; + + /** @var array */ + private const array FUNCTION_OVERRIDES = [ + 'std::bigint' => 'bcmath', + 'std::bigfloat' => 'bcmath', + 'std::decimal' => 'bcmath', + ]; + + /** @var array */ + private const array STANDARD_DIRECT_FEATURES = [ + 'count' => 'standard.array', + 'sizeof' => 'standard.array', + 'in_array' => 'standard.array', + 'array_search' => 'standard.array', + 'array_key_first' => 'standard.array', + 'array_key_last' => 'standard.array', + 'strlen' => 'standard.string', + 'trim' => 'standard.string', + 'ltrim' => 'standard.string', + 'rtrim' => 'standard.string', + 'strtolower' => 'standard.string', + 'strtoupper' => 'standard.string', + 'lcfirst' => 'standard.string', + 'ucfirst' => 'standard.string', + 'ucwords' => 'standard.string', + 'explode' => 'standard.string', + 'implode' => 'standard.string', + 'strpos' => 'standard.string', + 'stripos' => 'standard.string', + 'strrpos' => 'standard.string', + 'strstr' => 'standard.string', + 'stristr' => 'standard.string', + 'substr' => 'standard.string', + 'dirname' => 'standard.string', + 'basename' => 'standard.string', + 'is_dir' => 'standard.filesystem', + 'is_file' => 'standard.filesystem', + 'file_exists' => 'standard.filesystem', + 'realpath' => 'standard.filesystem', + 'round' => 'standard.math', + 'md5' => 'standard.hash', + 'sha1' => 'standard.hash', + 'version_compare' => 'standard.misc', + 'print_r' => 'standard.misc', + 'uniqid' => 'standard.misc', + 'parse_str' => 'standard.misc', + 'class_exists' => 'standard.core', + 'interface_exists' => 'standard.core', + 'trait_exists' => 'standard.core', + 'enum_exists' => 'standard.core', + 'function_exists' => 'standard.core', + 'method_exists' => 'standard.core', + 'property_exists' => 'standard.core', + 'is_a' => 'standard.core', + 'is_subclass_of' => 'standard.core', + 'defined' => 'standard.core', + 'define' => 'standard.core', + 'get_parent_class' => 'standard.core', + ]; + + /** @var array */ + private const array STANDARD_FUNCTION_FEATURES = [ + 'compact' => 'standard.array', + 'current' => 'standard.array', + 'end' => 'standard.array', + 'key' => 'standard.array', + 'max' => 'standard.array', + 'min' => 'standard.array', + 'next' => 'standard.array', + 'pos' => 'standard.array', + 'prev' => 'standard.array', + 'range' => 'standard.array', + 'reset' => 'standard.array', + 'sort' => 'standard.array', + 'rsort' => 'standard.array', + 'asort' => 'standard.array', + 'arsort' => 'standard.array', + 'ksort' => 'standard.array', + 'krsort' => 'standard.array', + 'usort' => 'standard.array', + 'uasort' => 'standard.array', + 'uksort' => 'standard.array', + 'base64_encode' => 'standard.encoding', + 'base64_decode' => 'standard.encoding', + 'htmlspecialchars' => 'standard.encoding', + 'htmlspecialchars_decode' => 'standard.encoding', + 'html_entity_decode' => 'standard.encoding', + 'htmlentities' => 'standard.encoding', + 'get_html_translation_table' => 'standard.encoding', + 'parse_url' => 'standard.encoding', + 'urlencode' => 'standard.encoding', + 'urldecode' => 'standard.encoding', + 'rawurlencode' => 'standard.encoding', + 'rawurldecode' => 'standard.encoding', + 'chdir' => 'standard.filesystem', + 'chroot' => 'standard.filesystem', + 'closedir' => 'standard.filesystem', + 'copy' => 'standard.filesystem', + 'disk_free_space' => 'standard.filesystem', + 'disk_total_space' => 'standard.filesystem', + 'getcwd' => 'standard.filesystem', + 'glob' => 'standard.filesystem', + 'mkdir' => 'standard.filesystem', + 'opendir' => 'standard.filesystem', + 'pathinfo' => 'standard.filesystem', + 'readdir' => 'standard.filesystem', + 'rename' => 'standard.filesystem', + 'rewinddir' => 'standard.filesystem', + 'rmdir' => 'standard.filesystem', + 'scandir' => 'standard.filesystem', + 'stat' => 'standard.filesystem', + 'lstat' => 'standard.filesystem', + 'tempnam' => 'standard.filesystem', + 'tmpfile' => 'standard.filesystem', + 'unlink' => 'standard.filesystem', + 'fprintf' => 'standard.format', + 'printf' => 'standard.format', + 'scanf' => 'standard.format', + 'sprintf' => 'standard.format', + 'sscanf' => 'standard.format', + 'vfprintf' => 'standard.format', + 'vprintf' => 'standard.format', + 'vsprintf' => 'standard.format', + 'md5' => 'standard.hash', + 'md5_file' => 'standard.hash', + 'sha1' => 'standard.hash', + 'sha1_file' => 'standard.hash', + 'phpcredits' => 'standard.info', + 'phpinfo' => 'standard.info', + 'phpversion' => 'standard.info', + 'php_ini_loaded_file' => 'standard.info', + 'php_ini_scanned_files' => 'standard.info', + 'php_sapi_name' => 'standard.info', + 'php_uname' => 'standard.info', + 'abs' => 'standard.math', + 'ceil' => 'standard.math', + 'floor' => 'standard.math', + 'fmod' => 'standard.math', + 'fdiv' => 'standard.math', + 'fpow' => 'standard.math', + 'intdiv' => 'standard.math', + 'number_format' => 'standard.math', + 'pi' => 'standard.math', + 'pow' => 'standard.math', + 'sqrt' => 'standard.math', + 'hrtime' => 'standard.misc', + 'microtime' => 'standard.misc', + 'gettimeofday' => 'standard.misc', + 'sleep' => 'standard.misc', + 'usleep' => 'standard.misc', + 'time_nanosleep' => 'standard.misc', + 'time_sleep_until' => 'standard.misc', + 'uniqid' => 'standard.misc', + 'version_compare' => 'standard.misc', + 'bin2hex' => 'standard.string', + 'chr' => 'standard.string', + 'explode' => 'standard.string', + 'hex2bin' => 'standard.string', + 'implode' => 'standard.string', + 'join' => 'standard.string', + 'nl2br' => 'standard.string', + 'ord' => 'standard.string', + 'parse_str' => 'standard.string', + 'strrev' => 'standard.string', + 'wordwrap' => 'standard.string', + 'boolval' => 'standard.type', + 'doubleval' => 'standard.type', + 'floatval' => 'standard.type', + 'get_debug_type' => 'standard.type', + 'gettype' => 'standard.type', + 'intval' => 'standard.type', + 'settype' => 'standard.type', + 'strval' => 'standard.type', + 'debug_zval_dump' => 'standard.var', + 'memory_get_peak_usage' => 'standard.var', + 'memory_get_usage' => 'standard.var', + 'memory_reset_peak_usage' => 'standard.var', + 'print_r' => 'standard.var', + 'serialize' => 'standard.var', + 'unserialize' => 'standard.var', + 'var_dump' => 'standard.var', + 'var_export' => 'standard.var', + 'constant' => 'standard.core', + 'ini_alter' => 'standard.core', + 'ini_get' => 'standard.core', + 'ini_get_all' => 'standard.core', + 'ini_parse_quantity' => 'standard.core', + 'ini_restore' => 'standard.core', + 'ini_set' => 'standard.core', + ]; + + /** @var array */ + private const array STANDARD_CLASS_FEATURES = [ + '__php_incomplete_class' => 'standard.var', + 'roundingmode' => 'standard.math', + 'sortdirection' => 'standard.array', + ]; + + /** + * @param list $availableExtensions + */ + public function select( + CompilationStatistics $statistics, + ?array $availableExtensions = null, + ): NanoExtensionSelection { + $availableExtensions ??= array_values(self::EXTENSION_NAMES); + $available = []; + foreach ($availableExtensions as $extension) { + $available[strtolower($extension)] = true; + } + ksort($available, SORT_STRING); + + $dynamic = array_keys($statistics->get(CompilationStatistics::DYNAMIC_CAPABILITIES)); + if ($dynamic !== []) { + return new NanoExtensionSelection( + array_keys($available), + true, + [], + array_map( + static fn(string $capability): string => "dynamic capability: {$capability}", + $dynamic, + ), + ); + } + + $selected = []; + $features = []; + foreach (array_keys($statistics->get(CompilationStatistics::RUNTIME_FUNCTIONS)) as $function) { + $owner = self::FUNCTION_OVERRIDES[strtolower($function)] + ?? $this->functionExtension($function); + if ($owner === 'standard') { + $feature = $this->standardFeature($function); + if ($feature === null) { + $selected['standard'] = true; + } else { + $features[$feature] = true; + } + continue; + } + if ($owner !== null && isset($available[$owner])) { + $selected[$owner] = true; + } + } + foreach (array_keys($statistics->get(CompilationStatistics::DIRECT_FUNCTIONS)) as $function) { + $owner = $this->functionExtension($function); + if ($owner === 'date') { + // Common date helpers are coupled to timelib and the DateTime + // implementation, so date remains one composition unit. + $selected['date'] = true; + continue; + } + if ($owner === 'standard') { + $feature = $this->standardFeature($function); + if ($feature === null) { + $selected['standard'] = true; + } else { + $features[$feature] = true; + } + continue; + } + if ($owner !== null && isset($available[$owner])) { + $selected[$owner] = true; + } + } + foreach (array_keys($statistics->get(CompilationStatistics::FUNCTIONS)) as $function) { + $owner = self::FUNCTION_OVERRIDES[strtolower($function)] ?? null; + if ($owner !== null && isset($available[$owner])) { + $selected[$owner] = true; + } + } + foreach (array_keys($statistics->get(CompilationStatistics::CLASSES)) as $class) { + $owner = $this->classExtension($class); + if ($owner === 'standard') { + $feature = self::STANDARD_CLASS_FEATURES[strtolower(ltrim($class, '\\'))] ?? null; + if ($feature === null) { + $selected['standard'] = true; + } else { + $features[$feature] = true; + } + continue; + } + if ($owner !== null && isset($available[$owner])) { + $selected[$owner] = true; + } + } + foreach ([Type::BIGINT, Type::BIGFLOAT, Type::DECIMAL] as $type) { + if ($statistics->has(CompilationStatistics::TYPES, $type) + && isset($available['bcmath']) + ) { + $selected['bcmath'] = true; + } + } + + $pending = array_keys($selected); + while (($extension = array_pop($pending)) !== null) { + foreach (self::DEPENDENCIES[$extension] ?? [] as $dependency) { + if (isset($available[$dependency]) && !isset($selected[$dependency])) { + $selected[$dependency] = true; + $pending[] = $dependency; + } + } + } + ksort($selected, SORT_STRING); + ksort($features, SORT_STRING); + return new NanoExtensionSelection(array_keys($selected), false, array_keys($features)); + } + + private function standardFeature(string $function): ?string + { + $function = strtolower($function); + $feature = self::STANDARD_DIRECT_FEATURES[$function] + ?? self::STANDARD_FUNCTION_FEATURES[$function] + ?? null; + if ($feature !== null) { + return $feature; + } + if (str_starts_with($function, 'array_')) { + return 'standard.array'; + } + if (str_starts_with($function, 'str_') || str_starts_with($function, 'substr_')) { + return 'standard.string'; + } + if (str_starts_with($function, 'file') || str_starts_with($function, 'stream_')) { + return 'standard.filesystem'; + } + if (str_starts_with($function, 'is_')) { + return in_array($function, ['is_dir', 'is_file', 'is_link', 'is_readable', 'is_writable', 'is_writeable', 'is_executable'], true) + ? 'standard.filesystem' + : 'standard.type'; + } + return null; + } + + private function functionExtension(string $name): ?string + { + if (!function_exists($name)) { + return null; + } + try { + return $this->normalizeExtension((new ReflectionFunction($name))->getExtensionName()); + } catch (ReflectionException) { + return null; + } + } + + private function classExtension(string $name): ?string + { + if (!class_exists($name, false) && !interface_exists($name, false)) { + return null; + } + try { + return $this->normalizeExtension((new ReflectionClass($name))->getExtensionName()); + } catch (ReflectionException) { + return null; + } + } + + private function normalizeExtension(string|false $extension): ?string + { + if (!is_string($extension)) { + return null; + } + return self::EXTENSION_NAMES[strtolower($extension)] ?? null; + } +} diff --git a/src/Build/NanoSourceComposer.php b/src/Build/NanoSourceComposer.php index 7e046275..e6d8bb9a 100644 --- a/src/Build/NanoSourceComposer.php +++ b/src/Build/NanoSourceComposer.php @@ -3,6 +3,7 @@ namespace TypePhp\Build; use RuntimeException; +use TypePhp\Analysis\CompilationStatistics; /** Resolves the Composer-provided runtime sources used by a Nano build. */ final class NanoSourceComposer @@ -12,10 +13,17 @@ final class NanoSourceComposer * packages: list, * packageSources: list, * includeDirs: list, - * registry: string + * defines: list, + * registry: string, + * selection: NanoExtensionSelection|null * } */ - public function compose(string $buildDir, string $targetName, bool $registerProject): array + public function compose( + string $buildDir, + string $targetName, + bool $registerProject, + ?CompilationStatistics $statistics = null, + ): array { $runtime = ComposerNativePackage::load('swoole/php-nano'); $phpx = ComposerNativePackage::load('swoole/phpx'); @@ -34,8 +42,16 @@ final class NanoSourceComposer } $packages = array_values($packagesByName); + $selection = $statistics === null + ? null + : (new NanoExtensionSelector())->select($statistics); + $activeComponents = $selection === null + ? null + : $this->selectComponents($packages, $selection); + $packageSources = []; $includeDirs = []; + $defines = []; foreach ($packages as $package) { if ($package->abi !== $runtime->abi) { throw new RuntimeException( @@ -43,29 +59,100 @@ final class NanoSourceComposer . "swoole/php-nano={$runtime->abi}" ); } - array_push($packageSources, ...$package->sources); + if ($activeComponents === null || $package->components === []) { + array_push($packageSources, ...$package->sources); + } else { + $ownedSources = []; + foreach ($package->components as $component) { + foreach ($component->sources as $source) { + $ownedSources[$source] = true; + } + if (isset($activeComponents[$package->name][$component->name])) { + array_push($packageSources, ...$component->sources); + array_push($defines, ...$component->defines); + } + } + foreach ($package->sources as $source) { + if (!isset($ownedSources[$source])) { + $packageSources[] = $source; + } + } + } array_push($includeDirs, ...$package->includeDirs); } + if ($activeComponents !== null) { + $defines[] = 'PHP_NANO_SELECTIVE=1'; + } return [ 'packages' => $packages, 'packageSources' => array_values(array_unique($packageSources)), 'includeDirs' => array_values(array_unique($includeDirs)), + 'defines' => array_values(array_unique($defines)), + 'selection' => $selection, 'registry' => $this->writeExtensionRegistry( $buildDir, $targetName, $packages, $registerProject, + $activeComponents, ), ]; } + /** + * @param list $packages + * @return array> + */ + private function selectComponents( + array $packages, + NanoExtensionSelection $selection, + ): array { + $extensionSet = array_fill_keys($selection->extensions, true); + $featureSet = array_fill_keys($selection->features, true); + $componentsByName = []; + foreach ($packages as $package) { + foreach ($package->components as $component) { + $componentsByName[$component->name][] = [$package, $component]; + } + } + + $active = []; + $pending = []; + foreach ($packages as $package) { + foreach ($package->components as $component) { + if (isset($extensionSet[$component->extension]) + || isset($featureSet[$component->name])) { + $active[$package->name][$component->name] = true; + $pending[] = $component; + } + } + } + while (($component = array_pop($pending)) !== null) { + foreach ($component->requires as $requirement) { + if (!isset($componentsByName[$requirement])) { + throw new RuntimeException( + "Nano component `{$component->name}` requires unknown component `{$requirement}`" + ); + } + foreach ($componentsByName[$requirement] ?? [] as [$package, $required]) { + if (!isset($active[$package->name][$required->name])) { + $active[$package->name][$required->name] = true; + $pending[] = $required; + } + } + } + } + return $active; + } + /** @param list $packages */ private function writeExtensionRegistry( string $buildDir, string $targetName, array $packages, bool $registerProject, + ?array $activeComponents, ): string { if (!is_dir($buildDir) && !mkdir($buildDir, 0777, true) && !is_dir($buildDir)) { throw new RuntimeException("Unable to create Nano build directory: {$buildDir}"); @@ -78,25 +165,37 @@ final class NanoSourceComposer $path = $buildDir . DIRECTORY_SEPARATOR . 'composer_extensions.cpp'; $declarations = []; $entries = []; + foreach ($packages as $package) { + foreach ($package->components as $component) { + if (($activeComponents !== null + && !isset($activeComponents[$package->name][$component->name])) + || $component->moduleEntry === null) { + continue; + } + $moduleEntry = $component->moduleEntry; + $declarations[$moduleEntry] = "extern \"C\" zend_module_entry {$moduleEntry};"; + $entries[$moduleEntry] = " &{$moduleEntry},"; + } + } foreach ($extensions as $extension) { $moduleEntry = $extension->extensionModuleEntry; - $declarations[] = "extern \"C\" zend_module_entry {$moduleEntry};"; - $entries[] = " &{$moduleEntry},"; + $declarations[$moduleEntry] = "extern \"C\" zend_module_entry {$moduleEntry};"; + $entries[$moduleEntry] = " &{$moduleEntry},"; } if ($registerProject) { $namespace = 'typephp_project_' . $targetName; $moduleEntry = 'typephp_' . $targetName . '_module_entry'; - $declarations[] = "namespace {$namespace} { extern zend_module_entry {$moduleEntry}; }"; - $entries[] = " &{$namespace}::{$moduleEntry},"; + $declarations[$moduleEntry] = "namespace {$namespace} { extern zend_module_entry {$moduleEntry}; }"; + $entries[$moduleEntry] = " &{$namespace}::{$moduleEntry},"; } $count = count($entries); $storageSize = max(1, $count); $contents = "#include \n\n" - . implode("\n", $declarations) . "\n\n" + . implode("\n", array_values($declarations)) . "\n\n" . "extern \"C\" zend_result php_nano_startup_composer_extensions() {\n" . " static zend_module_entry *extensions[{$storageSize}] = {\n" - . implode("\n", $entries) . "\n" + . implode("\n", array_values($entries)) . "\n" . " };\n" . " return php_nano_startup_extensions(extensions, {$count});\n" . "}\n\n" diff --git a/src/Build/NativeCommandOptionsTrait.php b/src/Build/NativeCommandOptionsTrait.php index 760326b8..86d2347c 100644 --- a/src/Build/NativeCommandOptionsTrait.php +++ b/src/Build/NativeCommandOptionsTrait.php @@ -41,6 +41,7 @@ trait NativeCommandOptionsTrait 'PHPX_NANO=1', '_POSIX_C_SOURCE=200809L', ); + array_push($userDefines, ...$this->nanoRuntimeDefines); if ($this->isWasiTarget()) { $userDefines[] = 'ZEND_MM_ERROR=0'; } diff --git a/src/Build/NativeSourceProjectBuilder.php b/src/Build/NativeSourceProjectBuilder.php index 621ade9e..682367af 100644 --- a/src/Build/NativeSourceProjectBuilder.php +++ b/src/Build/NativeSourceProjectBuilder.php @@ -39,6 +39,7 @@ final class NativeSourceProjectBuilder $generatedSources = []; $generatedIncludeDir = null; + $translator = null; if ($project->phpSources !== []) { $this->writeProgress( 'Generating C++ from ' . count($project->phpSources) . ' TypePHP source file(s)' @@ -59,6 +60,7 @@ final class NativeSourceProjectBuilder $project->buildDir, $this->projectSymbolName($project), $project->phpSources !== [], + $translator?->getCompilationStatistics(), ); $packages = $composition['packages']; $extensionRegistry = $composition['registry']; @@ -73,6 +75,14 @@ final class NativeSourceProjectBuilder $sources = array_values(array_unique($sources)); $includeDirs = array_values(array_unique($includeDirs)); $sourceCount = count($sources); + $selection = $composition['selection']; + if ($selection !== null) { + $selected = [...$selection->extensions, ...$selection->features]; + $this->writeProgress( + 'Nano capability selection: ' . ($selected === [] ? 'core only' : implode(', ', $selected)) + . ($selection->completeFallback ? ' (complete fallback)' : ''), + ); + } $this->writeProgress( "Resolved {$sourceCount} C/C++ source file(s) from " . count($packages) . ' Composer package(s)' ); @@ -99,6 +109,10 @@ final class NativeSourceProjectBuilder '-DPHP_NANO=1', '-DPHPX_NANO=1', '-D_POSIX_C_SOURCE=200809L', + ...array_map( + static fn(string $define): string => '-D' . $define, + $composition['defines'], + ), '-ffunction-sections', '-fdata-sections', // Package headers are passed with -isystem to suppress upstream diff --git a/src/Build/SourcePipelineTrait.php b/src/Build/SourcePipelineTrait.php index 18da9161..68222fcc 100644 --- a/src/Build/SourcePipelineTrait.php +++ b/src/Build/SourcePipelineTrait.php @@ -334,9 +334,11 @@ trait SourcePipelineTrait public function convert(array $files): array { - $this->composeTraitDeclarations($files); - $previousPhase = $this->enterCompilerPhase(self::PHASE_CONVERT); + $this->compilationStatistics->begin(); + $previousPhase = null; try { + $this->composeTraitDeclarations($files); + $previousPhase = $this->enterCompilerPhase(self::PHASE_CONVERT); // All declarations are now known. Lower declaration constant // expressions before translating any function body so cache IDs // are assigned exclusively in the convert phase. @@ -392,7 +394,10 @@ trait SourcePipelineTrait return $sourceFiles; } finally { - $this->restoreCompilerPhase($previousPhase); + if ($previousPhase !== null) { + $this->restoreCompilerPhase($previousPhase); + } + $this->compilationStatistics->finish(); } } } diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 34459685..38b708ef 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -8,6 +8,7 @@ namespace TypePhp; +use TypePhp\Analysis\CompilationStatistics; use TypePhp\Build\PhpxLocator; use League\CLImate\CLImate; @@ -529,6 +530,8 @@ class CompilerBase implements PropertyAccessContext protected bool $nanoPolicyMode = false; /** @var list Include directories published by Nano Composer packages. */ protected array $nanoRuntimeIncludePaths = []; + /** @var list */ + protected array $nanoRuntimeDefines = []; /** @var array Package source files compiled into a Nano executable. */ protected array $nanoRuntimeSources = []; /** Latest header timestamp used by the shared object-cache path. */ @@ -653,6 +656,9 @@ class CompilerBase implements PropertyAccessContext */ protected array $classSubClasses = []; + /** Whole-program usage collected during the convert phase. */ + protected CompilationStatistics $compilationStatistics; + public function __construct(string $rootPath) { $this->osType = PHP_OS_FAMILY; @@ -663,6 +669,7 @@ class CompilerBase implements PropertyAccessContext $this->error('PHP 8.6.0 or later is not supported'); } $this->rootPath = $rootPath; + $this->compilationStatistics = new CompilationStatistics(); $this->symbols = new SymbolRepository(); $this->setPhpVersion(self::DEFAULT_PHP_VERSION); $this->printer = new PrettyPrinter\Standard(); @@ -927,7 +934,38 @@ class CompilerBase implements PropertyAccessContext public function getTypeFromZendType(string $type): string { - return $this->zendTypeMap[$type] ?? self::PHP_RUNTIME_TYPE_MAP[$type] ?? Type::VAR; + $resolved = $this->zendTypeMap[$type] ?? self::PHP_RUNTIME_TYPE_MAP[$type] ?? Type::VAR; + $this->compilationStatistics->record(CompilationStatistics::TYPES, $resolved); + return $resolved; + } + + public function getCompilationStatistics(): CompilationStatistics + { + return $this->compilationStatistics; + } + + /** Record value types that survived into an emitted translation unit. */ + protected function recordEmittedTypes(string $code): void + { + foreach ([ + Type::VAR, + Type::BOOL, + Type::INT, + Type::FLOAT, + Type::STR, + Type::ARRAY, + Type::OBJECT, + Type::RESOURCE, + Type::STREAM, + Type::BIGINT, + Type::BIGFLOAT, + Type::DECIMAL, + Type::BOX, + ] as $type) { + if (str_contains($code, $type)) { + $this->compilationStatistics->record(CompilationStatistics::TYPES, $type); + } + } } public function getObjectType(string $object): string @@ -1537,6 +1575,10 @@ class CompilerBase implements PropertyAccessContext protected function getClassEntryPtr(string $className): string { + $this->compilationStatistics->record( + CompilationStatistics::CLASSES, + ltrim($className, '\\'), + ); $id = $this->getClassId($className); $persistent = isset($this->persistentClassMap[$className]); $helper = $persistent ? 'get_persistent_class' : 'get_class'; diff --git a/src/Parser/FunctionCallTrait.php b/src/Parser/FunctionCallTrait.php index 65b972ca..a1de091c 100644 --- a/src/Parser/FunctionCallTrait.php +++ b/src/Parser/FunctionCallTrait.php @@ -7,6 +7,7 @@ namespace TypePhp\Parser; +use TypePhp\Analysis\CompilationStatistics; use TypePhp\Type; use PhpParser\Node; @@ -104,12 +105,20 @@ trait FunctionCallTrait } if ($this->isVarExpr($expr->name)) { + $this->compilationStatistics->record( + CompilationStatistics::DYNAMIC_CAPABILITIES, + 'function-call', + ); $fn = $this->parseIdentifier($expr->name); $placeHolder = $fn; $name = ''; } elseif ($expr->name->getType() === 'Name' or $expr->name->getType() === 'Name_FullyQualified') { $name = $this->parseIdentifier($expr->name); $globalName = ltrim($name, '\\'); + $this->compilationStatistics->record( + CompilationStatistics::FUNCTIONS, + strtolower($globalName), + ); $namedExit = $this->parseNamedExitMessageCall($globalName, $expr); if ($namedExit !== null) { return $namedExit; @@ -185,14 +194,30 @@ trait FunctionCallTrait $this->checkInternalFunctionArgCount($name, $expr); $code = $this->parseFuncCallWithOptimizer($name, $expr); if ($code !== false) { + // Constant folding and native container operations do not + // retain the PHP function. Record only emitted stdlib calls. + if (str_contains($code, 'php::fn::')) { + $this->compilationStatistics->record( + CompilationStatistics::DIRECT_FUNCTIONS, + strtolower($globalName), + ); + } return $code; } + $this->compilationStatistics->record( + CompilationStatistics::RUNTIME_FUNCTIONS, + strtolower($globalName), + ); $placeHolder = $this->identifierToStr($expr->name); $fn = $this->getFuncPtr($name); if ($this->debug) { $this->context->beforeStmtLines[] = $this->formatCppLineComment('Func Call: ', $name . '()'); } } else { + $this->compilationStatistics->record( + CompilationStatistics::DYNAMIC_CAPABILITIES, + 'function-call', + ); $tmpVar = $this->addTmpVar(Type::VAR); $this->context->beforeStmtLines[] = $tmpVar . ' = ' . $this->parseExpr($expr->name) . ';'; $placeHolder = $fn = $tmpVar; diff --git a/src/Parser/UniversalMethodCall.php b/src/Parser/UniversalMethodCall.php index e9e5d8a7..fd830982 100644 --- a/src/Parser/UniversalMethodCall.php +++ b/src/Parser/UniversalMethodCall.php @@ -2,6 +2,7 @@ namespace TypePhp\Parser; +use TypePhp\Analysis\CompilationStatistics; use TypePhp\Type; use TypePhp\CompilerBase; @@ -600,6 +601,17 @@ trait UniversalMethodCall */ protected function parseUniversalMethodCall(Node\Expr\MethodCall $expr, string $receiver, string $method, array $def, bool $isVar = true): ?string { + if ($def['handler'] === 'php_fn') { + $function = strtolower($def['fn']); + $this->compilationStatistics->record(CompilationStatistics::FUNCTIONS, $function); + $this->compilationStatistics->record(CompilationStatistics::DIRECT_FUNCTIONS, $function); + } elseif ($def['handler'] === 'cpp_fn' + && str_starts_with($def['fn'], 'php::fn::') + ) { + $function = strtolower(substr($def['fn'], strlen('php::fn::'))); + $this->compilationStatistics->record(CompilationStatistics::FUNCTIONS, $function); + $this->compilationStatistics->record(CompilationStatistics::DIRECT_FUNCTIONS, $function); + } if ($this->isWasiTarget()) { if ($def['handler'] === 'php_fn') { $this->assertWasiFunctionSupported($expr, $def['fn']); diff --git a/src/Translator.php b/src/Translator.php index d7ed3168..b3a465b6 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -721,12 +721,17 @@ class Translator extends Preprocessor public function convertFile(string $file): ?string { + $ownsStatisticsSession = !$this->compilationStatistics->isCollecting(); + if ($ownsStatisticsSession) { + $this->compilationStatistics->begin(); + } // Public embedding/test callers may prepare files directly instead of // using SourcePipelineTrait::prepare(). Preserve the same explicit // prepare -> Trait composition -> convert ordering for that API. - $this->composeTraitDeclarations(array_keys($this->preparedFileAsts)); - $previousPhase = $this->enterCompilerPhase(self::PHASE_CONVERT); + $previousPhase = null; try { + $this->composeTraitDeclarations(array_keys($this->preparedFileAsts)); + $previousPhase = $this->enterCompilerPhase(self::PHASE_CONVERT); if (!$this->declarationExpressionsFinalized) { $this->finalizeDeclarationExpressions(array_keys($this->preparedFileAsts)); } @@ -737,6 +742,7 @@ class Translator extends Preprocessor while (true) { try { $cppCode = $this->doConvert($phpCode); + $this->recordEmittedTypes($cppCode); $cppFile = $this->getCppFile($file); if ($cppCode === '') { $this->removeEmptyTranslationUnitArtifacts($cppFile); @@ -752,7 +758,12 @@ class Translator extends Preprocessor } } } finally { - $this->restoreCompilerPhase($previousPhase); + if ($previousPhase !== null) { + $this->restoreCompilerPhase($previousPhase); + } + if ($ownsStatisticsSession) { + $this->compilationStatistics->finish(); + } } } @@ -2008,8 +2019,10 @@ CODE; $this->getBuildDir(), $this->targetName, true, + $this->getCompilationStatistics(), ); $this->nanoRuntimeIncludePaths = $composition['includeDirs']; + $this->nanoRuntimeDefines = $composition['defines']; $this->nanoRuntimeSources = array_fill_keys($composition['packageSources'], true); $this->nanoRuntimeHeaderMtime = $this->latestNanoHeaderMtime( $this->nanoRuntimeIncludePaths,