diff --git a/CHANGELOG.md b/CHANGELOG.md index 35c1fe4..2101e39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.0.3] - Unreleased +### Added + - Implemented support in the AST traversal for the PHP statements `include`, `include_once`, `require`, and + `require_once`. These statements are transformed into function calls. With this change, ncc can correctly handle and + import files from system packages or direct binary package files. + ### Fixed - When finding package versions in the package lock, ncc will try to find a satisfying version rather than the exact version, this is to prevent errors when the package lock contains a version that is not available in the repository. +- Fixed issue when registering ncc's extension, when using the INSTALLER, the installation path used in the process + appears to be incorrect, added a optional parameter to the `registerExtension` method to allow the installer to pass + the correct installation path. ## [2.0.2] - 2023-10-13 diff --git a/src/installer/installer b/src/installer/installer index b159761..f628c37 100644 --- a/src/installer/installer +++ b/src/installer/installer @@ -242,23 +242,6 @@ $NCC_FILESYSTEM->mkdir($NCC_INSTALL_PATH, 0755); - try - { - if(is_file(__DIR__ . DIRECTORY_SEPARATOR . 'default_repositories.json')) - { - Functions::initializeFiles(Functions::loadJsonFile(__DIR__ . DIRECTORY_SEPARATOR . 'default_repositories.json', Functions::FORCE_ARRAY)); - } - else - { - Functions::initializeFiles(); - } - } - catch(Exception $e) - { - Console::outException('Cannot initialize NCC files, ' . $e->getMessage(), $e, 1); - return; - } - // Copy files to the installation path try { @@ -302,6 +285,24 @@ Console::inlineProgressBar($processed_items, $total_items); } + // Initialize ncc's files + try + { + if(is_file(__DIR__ . DIRECTORY_SEPARATOR . 'default_repositories.json')) + { + Functions::initializeFiles($NCC_INSTALL_PATH, Functions::loadJsonFile(__DIR__ . DIRECTORY_SEPARATOR . 'default_repositories.json', Functions::FORCE_ARRAY)); + } + else + { + Functions::initializeFiles($NCC_INSTALL_PATH); + } + } + catch(Exception $e) + { + Console::outException('Cannot initialize NCC files, ' . $e->getMessage(), $e, 1); + return; + } + // Generate executable shortcut try diff --git a/src/ncc/CLI/Commands/SetupCommand.php b/src/ncc/CLI/Commands/SetupCommand.php index 834f843..f6f5bc0 100644 --- a/src/ncc/CLI/Commands/SetupCommand.php +++ b/src/ncc/CLI/Commands/SetupCommand.php @@ -58,7 +58,7 @@ try { - Functions::initializeFiles($default_repositories); + Functions::initializeFiles(null, $default_repositories); } catch(Exception $e) { diff --git a/src/ncc/Classes/PackageReader.php b/src/ncc/Classes/PackageReader.php index 07d71ec..4123a1a 100644 --- a/src/ncc/Classes/PackageReader.php +++ b/src/ncc/Classes/PackageReader.php @@ -639,6 +639,25 @@ return Resource::fromArray(ZiProto::decode($this->getByPointer($pointer, $length))); } + /** + * Searches the package's directory for a file that matches the given filename + * + * @param string $filename + * @return string|false + */ + public function find(string $filename): string|false + { + foreach($this->headers[PackageStructure::DIRECTORY] as $name => $location) + { + if(str_ends_with($name, $filename)) + { + return $name; + } + } + + return false; + } + /** * Returns the offset of the package * diff --git a/src/ncc/Classes/PhpExtension/AstWalker.php b/src/ncc/Classes/PhpExtension/AstWalker.php index 622f858..8e2f890 100644 --- a/src/ncc/Classes/PhpExtension/AstWalker.php +++ b/src/ncc/Classes/PhpExtension/AstWalker.php @@ -24,6 +24,7 @@ use ncc\ThirdParty\nikic\PhpParser\Comment; use ncc\ThirdParty\nikic\PhpParser\Node; + use ncc\ThirdParty\nikic\PhpParser\NodeTraverser; use ReflectionClass; use ReflectionException; use RuntimeException; @@ -60,9 +61,10 @@ */ public static function extractClasses(Node|array $node, string $prefix=''): array { + $classes = []; + if(is_array($node)) { - $classes = []; foreach($node as $sub_node) { /** @noinspection SlowArrayOperationsInLoopInspection */ @@ -71,8 +73,6 @@ return $classes; } - $classes = []; - if ($node instanceof Node\Stmt\ClassLike) { $classes[] = $prefix . $node->name; @@ -80,9 +80,9 @@ if ($node instanceof Node\Stmt\Namespace_) { - if ($node->name && $node->name->parts) + if ($node->name && $node->name->getParts()) { - $prefix .= implode('\\', $node->name->parts) . '\\'; + $prefix .= implode('\\', $node->name->getParts()) . '\\'; } else { @@ -254,4 +254,19 @@ throw new RuntimeException("Unknown node type \"$nodeType\""); } + + /** + * Transforms include, include_once, require and require_once statements into function calls. + * + * @param Node|array $stmts The AST node or array of nodes to transform. + * @param string|null $package Optionally. The package name to pass to the transformed function calls. + * @return Node|array The transformed AST node or array of nodes. + */ + public static function transformRequireCalls(Node|array $stmts, ?string $package=null): Node|array + { + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ExpressionTraverser($package)); + + return $traverser->traverse($stmts); + } } \ No newline at end of file diff --git a/src/ncc/Classes/PhpExtension/ExpressionTraverser.php b/src/ncc/Classes/PhpExtension/ExpressionTraverser.php new file mode 100644 index 0000000..17ac6eb --- /dev/null +++ b/src/ncc/Classes/PhpExtension/ExpressionTraverser.php @@ -0,0 +1,74 @@ +package = $package; + } + + /** + * @param Node $node + * @return array|int|Node|null + */ + public function leaveNode(Node $node): array|int|Node|null + { + if($node instanceof Node\Expr\Include_) + { + Console::outDebug(sprintf('Processing ExpressionTraverser on: %s', $node->getType())); + $args = [$node->expr]; + + if(!is_null($this->package)) + { + $args[] = new Node\Arg(new Node\Scalar\String_($this->package)); + } + + $types = [ + Node\Expr\Include_::TYPE_INCLUDE => '\ncc\Classes\Runtime::runtimeInclude', + Node\Expr\Include_::TYPE_INCLUDE_ONCE => '\ncc\Classes\Runtime::runtimeIncludeOnce', + Node\Expr\Include_::TYPE_REQUIRE => '\ncc\Classes\Runtime::runtimeRequire', + Node\Expr\Include_::TYPE_REQUIRE_ONCE => '\ncc\Classes\Runtime::runtimeRequireOnce', + ]; + + return new Node\Expr\FuncCall(new Node\Name($types[$node->type]), $args); + } + + return null; + } + } \ No newline at end of file diff --git a/src/ncc/Classes/PhpExtension/NccCompiler.php b/src/ncc/Classes/PhpExtension/NccCompiler.php index 0cdf5e3..b1d1736 100644 --- a/src/ncc/Classes/PhpExtension/NccCompiler.php +++ b/src/ncc/Classes/PhpExtension/NccCompiler.php @@ -53,6 +53,9 @@ try { $stmts = (new ParserFactory())->create(ParserFactory::PREFER_PHP7)->parse(IO::fread($file_path)); + $stmts = AstWalker::transformRequireCalls( + $stmts, $this->getProjectManager()->getProjectConfiguration()->getAssembly()->getPackage() + ); $component = new Component($component_name, ZiProto::encode($stmts), ComponentDataType::AST); $component->addFlag(ComponentFlags::PHP_AST); diff --git a/src/ncc/Classes/Runtime.php b/src/ncc/Classes/Runtime.php index 63d3e6e..7800e67 100644 --- a/src/ncc/Classes/Runtime.php +++ b/src/ncc/Classes/Runtime.php @@ -29,6 +29,8 @@ use ncc\Enums\FileDescriptor; use ncc\Enums\Flags\PackageFlags; use ncc\Enums\Options\BuildConfigurationOptions; + use ncc\Enums\Options\ComponentDecodeOptions; + use ncc\Enums\PackageDirectory; use ncc\Enums\Versions; use ncc\Exceptions\ConfigurationException; use ncc\Exceptions\ImportException; @@ -39,8 +41,12 @@ use ncc\Extensions\ZiProto\ZiProto; use ncc\Managers\PackageManager; use ncc\Objects\Package\Metadata; + use ncc\Utilities\Console; use ncc\Utilities\IO; + use ncc\Utilities\Resolver; + use ncc\Utilities\Validate; use RuntimeException; + use Throwable; class Runtime { @@ -59,6 +65,11 @@ */ private static $package_manager; + /** + * @var array + */ + private static $included_files = []; + /** * Executes the main execution point of an imported package and returns the evaluated result * This method may exit the program without returning a value @@ -170,6 +181,7 @@ } $entry = self::getPackageManager()->getPackageLock()->getEntry($package); + self::$imported_packages[$package] = $entry->getPath($version); foreach($entry->getClassMap($version) as $class => $component_name) { @@ -197,8 +209,6 @@ } } - self::$imported_packages[$package] = $entry->getPath($version); - if(isset($entry->getMetadata($version)->getOptions()[PackageFlags::STATIC_DEPENDENCIES])) { // Fake import the dependencies @@ -345,7 +355,6 @@ if(is_string(self::$class_map[$class]) && is_file(self::$class_map[$class])) { require_once self::$class_map[$class]; - return; } } @@ -361,4 +370,274 @@ return self::$package_manager; } + + /** + * Returns an array of included files both from the php runtime and ncc runtime + * + * @return array + */ + public static function runtimeGetIncludedFiles(): array + { + return array_merge(get_included_files(), self::$included_files); + } + + /** + * Evaluates and executes PHP code with error handling, this function + * gracefully handles tags and exceptions the same way as the + * require/require_once/include/include_once expressions + * + * @param string $code The PHP code to be executed + */ + public static function extendedEvaluate(string $code): void + { + if(ob_get_level() > 0) + { + ob_clean(); + } + + $exceptions = []; + $code = preg_replace_callback('/<\?php(.*?)\?>/s', static function ($matches) use (&$exceptions) + { + ob_start(); + + try + { + eval($matches[1]); + } + catch (Throwable $e) + { + $exceptions[] = $e; + } + + return ob_get_clean(); + }, $code); + + ob_start(); + + try + { + eval('?>' . $code); + } + catch (Throwable $e) + { + $exceptions[] = $e; + } + + if (!empty($exceptions)) + { + print(ob_get_clean()); + + $exception_stack = null; + foreach ($exceptions as $e) + { + if($exception_stack === null) + { + $exception_stack = $e; + } + else + { + $exception_stack = new Exception($exception_stack->getMessage(), $exception_stack->getCode(), $e); + } + } + + throw new RuntimeException('An exception occurred while evaluating the code', 0, $exception_stack); + } + + print(ob_get_clean()); + } + + /** + * Returns the content of the aquired file + * + * @param string $path + * @param string|null $package + * @return string + * @throws ConfigurationException + * @throws IOException + * @throws OperationException + * @throws PathNotFoundException + */ + private static function acquireFile(string $path, ?string $package=null): string + { + $cwd_checked = false; // sanity check to prevent checking the cwd twice + + // Check if the file is absolute + if(is_file($path)) + { + Console::outDebug(sprintf('Acquired file "%s" from absolute path', $path)); + return IO::fread($path); + } + + // Since $package is not null, let's try to acquire the file from the package + if($package !== null && isset(self::$imported_packages[$package])) + { + $base_path = basename($path); + + if(self::$imported_packages[$package] instanceof PackageReader) + { + $acquired_file = self::$imported_packages[$package]->find($base_path); + Console::outDebug(sprintf('Acquired file "%s" from package "%s"', $path, $package)); + + return match (Resolver::componentType($acquired_file)) + { + PackageDirectory::RESOURCES => self::$imported_packages[$package]->getResource(Resolver::componentName($acquired_file))->getData(), + PackageDirectory::COMPONENTS => self::$imported_packages[$package]->getComponent(Resolver::componentName($acquired_file))->getData([ComponentDecodeOptions::AS_FILE]), + default => throw new IOException(sprintf('Unable to acquire file "%s" from package "%s" because it is not a resource or component', $path, $package)), + }; + } + + if(is_dir(self::$imported_packages[$package])) + { + $base_path = basename($path); + foreach(IO::scan(self::$imported_packages[$package]) as $file) + { + if(str_ends_with($file, $base_path)) + { + Console::outDebug(sprintf('Acquired file "%s" from package "%s"', $path, $package)); + return IO::fread($file); + } + } + } + } + + // If not, let's try the include_path + foreach(explode(PATH_SEPARATOR, get_include_path()) as $file_path) + { + if($file_path === '.' && !$cwd_checked) + { + $cwd_checked = true; + $file_path = getcwd(); + } + + if(is_file($file_path . DIRECTORY_SEPARATOR . $path)) + { + Console::outDebug(sprintf('Acquired file "%s" from include_path', $path)); + return IO::fread($file_path . DIRECTORY_SEPARATOR . $path); + } + + if(is_file($file_path . DIRECTORY_SEPARATOR . basename($path))) + { + Console::outDebug(sprintf('Acquired file "%s" from include_path (using basename)', $path)); + return IO::fread($file_path . DIRECTORY_SEPARATOR . basename($path)); + } + } + + // Check the current working directory + if(!$cwd_checked) + { + if(is_file(getcwd() . DIRECTORY_SEPARATOR . $path)) + { + Console::outDebug(sprintf('Acquired file "%s" from current working directory', $path)); + return IO::fread(getcwd() . DIRECTORY_SEPARATOR . $path); + } + + if(is_file(getcwd() . DIRECTORY_SEPARATOR . basename($path))) + { + Console::outDebug(sprintf('Acquired file "%s" from current working directory (using basename)', $path)); + return IO::fread(getcwd() . DIRECTORY_SEPARATOR . basename($path)); + } + } + + // Check the calling script's directory + $called_script_directory = dirname(debug_backtrace()[0]['file']); + $file_path = $called_script_directory . DIRECTORY_SEPARATOR . $path; + if(is_file($file_path)) + { + Console::outDebug(sprintf('Acquired file "%s" from calling script\'s directory', $path)); + return IO::fread($file_path); + } + + throw new IOException(sprintf('Unable to acquire file "%s" because it does not exist', $path)); + } + + /** + * Includes a file at runtime + * + * @param string $path + * @param string|null $package + * @return void + */ + public static function runtimeInclude(string $path, ?string $package=null): void + { + try + { + $acquired_file = self::acquireFile($path, $package); + } + catch(Exception $e) + { + $package ? + Console::outWarning(sprintf('Failed to acquire file "%s" from package "%s" at runtime: %s', $path, $package, $e->getMessage())) : + Console::outWarning(sprintf('Failed to acquire file "%s" at runtime: %s', $path, $e->getMessage())); + + return; + } + + if(!in_array($path, self::$included_files, true)) + { + self::$included_files[] = $path; + } + + self::extendedEvaluate($acquired_file); + } + + /** + * Includes a file at runtime if it's not already included + * + * @param string $path + * @param string|null $package + * @return void + */ + public static function runtimeIncludeOnce(string $path, ?string $package=null): void + { + if(in_array($path, self::runtimeGetIncludedFiles(), true)) + { + return; + } + + self::runtimeInclude($path, $package); + } + + /** + * Requires a file at runtime, throws an exception if the file failed to require + * + * @param string $path + * @param string|null $package + * @return void + */ + public static function runtimeRequire(string $path, ?string $package=null): void + { + try + { + $acquired_file = self::acquireFile($path, $package); + } + catch(Exception $e) + { + $package ? + throw new RuntimeException(sprintf('Failed to acquire file "%s" from package "%s" at runtime: %s', $path, $package, $e->getMessage()), $e->getCode(), $e) : + throw new RuntimeException(sprintf('Failed to acquire file "%s" at runtime: %s', $path, $e->getMessage()), $e->getCode(), $e); + } + + if(!in_array($path, self::$included_files, true)) + { + self::$included_files[] = $path; + } + + self::extendedEvaluate($acquired_file); + } + + /** + * Requires a file at runtime if it's not already required + * + * @param string $path + * @return void + */ + public static function runtimeRequireOnce(string $path): void + { + if(in_array($path, self::runtimeGetIncludedFiles(), true)) + { + return; + } + + self::runtimeRequire($path); + } } \ No newline at end of file diff --git a/src/ncc/Utilities/Functions.php b/src/ncc/Utilities/Functions.php index fdad63e..b6a1f9a 100644 --- a/src/ncc/Utilities/Functions.php +++ b/src/ncc/Utilities/Functions.php @@ -285,13 +285,13 @@ } /** - * Initializes ncc system files + * Initializes the necessary files and directories for the ncc application * - * @param array $default_repositories - * @return void + * @param string|null $install_path The installation path of ncc (optional) + * @param array $default_repositories The default repositories to initialize (optional) * @throws OperationException */ - public static function initializeFiles(array $default_repositories=[]): void + public static function initializeFiles(?string $install_path=null, array $default_repositories=[]): void { if(Resolver::resolveScope() !== Scopes::SYSTEM) { @@ -342,7 +342,7 @@ try { - self::registerExtension($filesystem); + self::registerExtension($filesystem, $install_path); } catch(Exception $e) { @@ -351,23 +351,30 @@ } /** - * Register the ncc extension with the given filesystem. + * Registers the ncc extension with the given filesystem and optional install path. * - * @param Filesystem $filesystem The filesystem object used for file operations. - * @throws IOException If the extension cannot be registered. - * @throws NotSupportedException If `get_include_path()` function is not available. - * @throws PathNotFoundException If the default include path is not available. + * @param Filesystem $filesystem The filesystem to register the extension with + * @param string|null $install_path The optional install path for the extension + * @return void + * @throws IOException + * @throws NotSupportedException + * @throws PathNotFoundException */ - private static function registerExtension(Filesystem $filesystem): void + private static function registerExtension(Filesystem $filesystem, ?string $install_path=null): void { if(!function_exists('get_include_path')) { throw new NotSupportedException('Cannot register ncc extension, get_include_path() is not available'); } + if($install_path === null) + { + $install_path = NCC_EXEC_LOCATION; + } + $default_share = DIRECTORY_SEPARATOR . 'usr' . DIRECTORY_SEPARATOR . 'share' . DIRECTORY_SEPARATOR . 'php'; $include_paths = explode(':', get_include_path()); - $extension = str_ireplace('%ncc_install', NCC_EXEC_LOCATION, IO::fread(__DIR__ . DIRECTORY_SEPARATOR . 'extension')); + $extension = str_ireplace('%ncc_install', $install_path, IO::fread(__DIR__ . DIRECTORY_SEPARATOR . 'extension')); if(in_array($default_share, $include_paths)) { diff --git a/src/ncc/Utilities/IO.php b/src/ncc/Utilities/IO.php index 12dfac7..aa90702 100644 --- a/src/ncc/Utilities/IO.php +++ b/src/ncc/Utilities/IO.php @@ -22,8 +22,10 @@ namespace ncc\Utilities; + use InvalidArgumentException; use ncc\Exceptions\IOException; use ncc\Exceptions\PathNotFoundException; + use RuntimeException; use SplFileInfo; use SplFileObject; @@ -122,4 +124,48 @@ Console::outDebug(sprintf('reading %s', $uri)); return $file->fread($length); } + + /** + * Returns an array of all files in the specified path + * + * @param string $path + * @param bool $recursive + * @return array + * @throws IOException + */ + public static function scan(string $path, bool $recursive = true): array + { + if (!is_readable($path)) + { + throw new IOException(sprintf('Directory does not exist or is not readable: %s', $path)); + } + + $files = []; + $items = scandir($path, SCANDIR_SORT_NONE); + + if ($items === false) + { + throw new IOException(sprintf('Unable to list directory items: %s', $path)); + } + + foreach ($items as $file) + { + if ($file === '.' || $file === '..') + { + continue; + } + + $file_path = $path . DIRECTORY_SEPARATOR . $file; + if (is_dir($file_path) && $recursive) + { + /** @noinspection SlowArrayOperationsInLoopInspection */ + $files = array_merge($files, self::scan($file_path)); + continue; + } + + $files[] = $file_path; + } + + return $files; + } } \ No newline at end of file diff --git a/src/ncc/Utilities/Resolver.php b/src/ncc/Utilities/Resolver.php index 19055a6..f58f0e9 100644 --- a/src/ncc/Utilities/Resolver.php +++ b/src/ncc/Utilities/Resolver.php @@ -24,7 +24,9 @@ namespace ncc\Utilities; + use InvalidArgumentException; use ncc\Enums\LogLevel; + use ncc\Enums\PackageDirectory; use ncc\Enums\Scopes; use ncc\Enums\Types\ProjectType; use ncc\Exceptions\NotSupportedException; @@ -256,4 +258,69 @@ return false; } + + /** + * Get the component type based on the file name + * + * @param string $component_path Component name + * @return int Component type + * @throws InvalidArgumentException If the component name is invalid + * @see PackageDirectory + */ + public static function componentType(string $component_path): int + { + // Check for empty string and presence of ":" + if (empty($component_path) || !str_contains($component_path, ':')) + { + throw new InvalidArgumentException(sprintf('Invalid component format "%s"', $component_path)); + } + + // Get the prefix before ":" and remove "@" character + $file_stub_code = str_ireplace('@', '', explode(':', $component_path, 2)[0]); + + // Check if the prefix is numeric + if (!is_numeric($file_stub_code)) + { + throw new InvalidArgumentException(sprintf('Invalid component prefix "%s"', $file_stub_code)); + } + + return match ((int)$file_stub_code) + { + PackageDirectory::METADATA => PackageDirectory::METADATA, + PackageDirectory::ASSEMBLY => PackageDirectory::ASSEMBLY, + PackageDirectory::EXECUTION_UNITS => PackageDirectory::EXECUTION_UNITS, + PackageDirectory::INSTALLER => PackageDirectory::INSTALLER, + PackageDirectory::DEPENDENCIES => PackageDirectory::DEPENDENCIES, + PackageDirectory::CLASS_POINTER => PackageDirectory::CLASS_POINTER, + PackageDirectory::RESOURCES => PackageDirectory::RESOURCES, + PackageDirectory::COMPONENTS => PackageDirectory::COMPONENTS, + default => throw new InvalidArgumentException(sprintf('Invalid component type "%s"', $component_path)), + }; + } + + /** + * Returns the component name based on the file name + * + * @param string $component_path + * @return string + */ + public static function componentName(string $component_path): string + { + // Check for empty string and presence of ":" + if (empty($component_path) || !str_contains($component_path, ':')) + { + throw new InvalidArgumentException(sprintf('Invalid component format "%s"', $component_path)); + } + + // Get the prefix before ":" and remove "@" character + $file_stub_code = str_ireplace('@', '', explode(':', $component_path, 2)[0]); + + // Check if the prefix is numeric + if (!is_numeric($file_stub_code)) + { + throw new InvalidArgumentException(sprintf('Invalid component prefix "%s"', $file_stub_code)); + } + + return explode(':', $component_path, 2)[1]; + } } \ No newline at end of file diff --git a/tests/autoload.php b/tests/autoload.php deleted file mode 100644 index 7708ba0..0000000 --- a/tests/autoload.php +++ /dev/null @@ -1,36 +0,0 @@ -