getPathname(); // Skip ignored directories if (in_array($path, $ignore_dirs)) { continue; } if ($item->isFile() || $item->isLink()) { unlink($path); } elseif ($item->isDir() && !in_array($path, $ignore_dirs)) { @rmdir($path); } } if ($delete_self) { return @rmdir($dir); } return true; } /** * Extract a list of values from a collection of arrays/objects * * @param array|object $array Collection to pluck from * @param string|null $key Key to extract (null for first element) * @return array Array of plucked values */ function array_pluck($array, $key = null) { $result = []; if (is_object($array)) { $array = (array) $array; } foreach ($array as $index => $item) { if (is_object($item)) { $item = (array) $item; } if ($key !== null) { if (isset($item[$key])) { $result[$index] = $item[$key]; } } else { // Get first element $result[$index] = reset($item); } } return $result; } /** * Get only specified keys from an array * * @param array $array Source array * @param array $keys Keys to keep * @return array Filtered array */ function array_only($array, $keys) { return array_intersect_key($array, array_flip($keys)); } /** * Get all except specified keys from an array * * @param array $array Source array * @param array $keys Keys to exclude * @return array Filtered array */ function array_except($array, $keys) { return array_diff_key($array, array_flip($keys)); } /** * Recursively merge arrays, combining array values instead of replacing * * Ported from array_intersperse() in RSpade v3. * * This function performs a deep merge of two arrays. Unlike array_merge_recursive() which * creates sub-arrays for duplicate keys, this function intelligently merges values: * - For numeric keys: appends unique values only (no duplicates) * - For string keys with array values: recursively merges the arrays * - For string keys with scalar values: replaces the target value with source value * * Accepts arrays by reference for efficiency but creates and returns a new merged array. * Does not modify the input arrays. * * @param array &$target Base array (not modified) * @param array &$source Array to merge (not modified) * @return array The merged result array * * @example * $target = [ * 'config' => ['debug' => true, 'env' => 'local'], * 'modules' => ['jquery', 'bootstrap'], * 'version' => '1.0' * ]; * $source = [ * 'config' => ['api_url' => '/api', 'env' => 'production'], * 'modules' => ['jquery', 'vue'], // jquery won't be duplicated * 'version' => '2.0' * ]; * $result = array_merge_deep($target, $source); * // Result in $result: * // [ * // 'config' => ['debug' => true, 'env' => 'production', 'api_url' => '/api'], * // 'modules' => ['jquery', 'bootstrap', 'vue'], * // 'version' => '2.0' * // ] */ function array_merge_deep(array &$target, array &$source): array { $result = $target; foreach ($source as $key => $value) { if (is_numeric($key)) { // Numeric key - append if not already present if (!in_array($value, $result, true)) { $result[] = $value; } } elseif (is_array($value)) { if (!isset($result[$key]) || !is_array($result[$key])) { $result[$key] = $value; } else { $result[$key] = array_merge_deep($result[$key], $value); } } else { $result[$key] = $value; } } return $result; } /** * Check if an array has string keys (is associative) * * @param array $array Array to check * @return bool True if has string keys */ function is_associative_array($array) { if (!is_array($array) || empty($array)) { return false; } return count(array_filter(array_keys($array), 'is_string')) > 0; } /** * Recursively convert objects to arrays * * @param mixed $object Object or value to convert * @return mixed Converted value */ function object_to_array_recursive($object) { if (is_object($object)) { // Check for to_array method if (method_exists($object, 'toArray')) { $object = $object->toArray(); } elseif (method_exists($object, 'to_array')) { $object = $object->to_array(); } else { $object = (array) $object; } } if (is_array($object)) { foreach ($object as $key => $value) { $object[$key] = object_to_array_recursive($value); } } return $object; } /** * Generate a cryptographically secure random hash * * @param int $bytes Number of random bytes (default: 32) * @return string Hexadecimal hash */ function random_hash($bytes = 32) { return bin2hex(random_bytes($bytes)); } /** * Make a string safe for use as a variable/function name * * @param string $string Input string * @param int $max_length Maximum length (default: 64) * @return string Safe string */ function safe_string($string, $max_length = 64) { // Replace non-alphanumeric with underscores $string = preg_replace('/[^a-zA-Z0-9_]+/', '_', $string); // Ensure first character is not a number if (empty($string) || is_numeric($string[0])) { $string = '_' . $string; } // Trim to max length return substr($string, 0, $max_length); } /** * Get file extension handling double extensions (e.g., .blade.php) * * @param string $path File path * @param bool $double Check for double extensions * @return string Extension */ function file_extension($path, $double = true) { $filename = basename($path); if ($double) { // Check for known double extensions $double_extensions = ['blade.php', 'spec.js', 'test.js', 'min.js', 'min.css', 'd.ts']; foreach ($double_extensions as $ext) { if (str_ends_with($filename, '.' . $ext)) { return $ext; } } } return pathinfo($filename, PATHINFO_EXTENSION); } /** * Get a view instance by RSX ID (path-agnostic identifier). * This allows views to be referenced by their ID instead of file path. * * @param string $id The ID defined in the view file with @rsx_id directive * @param array $data Data to pass to the view * @param array $merge_data Data to merge with the view data * @return \Illuminate\View\View|\Illuminate\Contracts\View\Factory * @throws \Exception if the view is not found */ function rsx_view($id, $data = [], $merge_data = []) { // Use manifest to find the view file by ID $file_path = \App\RSpade\Core\Manifest\Manifest::find_view($id); if (!$file_path) { throw new \InvalidArgumentException("RSX view not found: {$id}"); } // Share the ID for use in layouts (for body class) \Illuminate\Support\Facades\View::share('rsx_view_id', $id); // Share the current view path for bundle validation \Illuminate\Support\Facades\View::share('rsx_current_view_path', $file_path); // In development mode, validate the view and layout chain if (!app()->environment('production')) { // Validate the entire layout chain and bundle placement \App\RSpade\Core\Validation\LayoutChainValidator::validate_layout_chain($id); // Get the metadata for the view $metadata = \App\RSpade\Core\Manifest\Manifest::get_file($file_path); // Validate the view doesn't have inline styles/scripts \App\RSpade\Core\Validation\ViewValidator::validate_view($id, $file_path, $metadata ?? []); // If the view extends a layout, validate each layout in the chain has body class if (isset($metadata['rsx_extends'])) { $current_extends = $metadata['rsx_extends']; $visited = []; // Prevent infinite loops while ($current_extends) { if (in_array($current_extends, $visited)) { break; // Circular dependency, let LayoutChainValidator handle it } $visited[] = $current_extends; $layout_path = \App\RSpade\Core\Manifest\Manifest::find_view($current_extends); if ($layout_path) { // Validate this layout \App\RSpade\Core\Validation\ViewValidator::validate_layout($layout_path); // Get metadata to find next parent $layout_metadata = \App\RSpade\Core\Manifest\Manifest::get_file($layout_path); $current_extends = $layout_metadata['rsx_extends'] ?? null; } else { break; } } } } // Convert to Laravel view name format // Remove .blade.php extension $view_name = preg_replace('/\.blade\.php$/', '', $file_path); // Handle different view locations if (str_starts_with($view_name, 'resources/views/')) { $view_name = substr($view_name, strlen('resources/views/')); } elseif (str_starts_with($view_name, 'rsx/')) { // For RSX views, use namespace format // The namespace 'rsx' is registered in RsxServiceProvider $view_name = str_replace('rsx/', 'rsx::', $view_name); } // Convert path separators to dots for Laravel $view_name = str_replace('/', '.', $view_name); return view($view_name, $data, $merge_data); } /** * Get the Laravel view path for a view by RSX ID. * Used internally by RSX Blade directives to resolve view paths. * * @param string $id The ID to look up * @return string|null The Laravel view path in dot notation or null if not found */ function rsx_view_path($id) { // Remove quotes if passed from Blade directive $id = trim($id, "'\""); // Use manifest to find the view file $file_path = \App\RSpade\Core\Manifest\Manifest::find_view($id); if (!$file_path) { return null; } // Convert to Laravel view path format // Remove .blade.php extension $view_path = preg_replace('/\.blade\.php$/', '', $file_path); // Handle different view locations if (str_starts_with($view_path, 'resources/views/')) { $view_path = substr($view_path, strlen('resources/views/')); } elseif (str_starts_with($view_path, 'rsx/')) { // For RSX views, convert to namespace format $view_path = str_replace('rsx/', 'rsx::', $view_path); } // Convert path separators to dots return str_replace('/', '.', $view_path); } /** * Create directory if it doesn't exist * * @param string $path Directory path * @param int $permissions Directory permissions * @return bool Success status */ function ensure_directory($path, $permissions = 0755) { if (!is_dir($path)) { return mkdir($path, $permissions, true); } return true; } /** * Get relative path from base path * * @param string $path Full path * @param string|null $base Base path (defaults to base_path()) * @return string Relative path */ function relative_path($path, $base = null) { if ($base === null) { $base = base_path(); } $base = rtrim($base, '/') . '/'; if (str_starts_with($path, $base)) { return substr($path, strlen($base)); } return $path; } /** * Copy directory recursively * * @param string $source Source directory * @param string $destination Destination directory * @param array $ignore Patterns to ignore * @return bool Success status */ function copy_directory($source, $destination, $ignore = []) { if (!is_dir($source)) { return false; } if (!is_dir($destination)) { mkdir($destination, 0755, true); } $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST, ); foreach ($iterator as $item) { $source_path = $item->getPathname(); $relative = relative_path($source_path, $source); $dest_path = $destination . '/' . $relative; // Check ignore patterns foreach ($ignore as $pattern) { if (fnmatch($pattern, $relative)) { continue 2; } } if ($item->isDir()) { if (!is_dir($dest_path)) { mkdir($dest_path, 0755, true); } } else { copy($source_path, $dest_path); } } return true; } /** * Recursively sort array by keys * * @param array $array Array to sort (by reference) * @param int $sort_flags Sort flags * @return bool Success status */ function ksort_recursive(&$array, $sort_flags = SORT_REGULAR) { if (!is_array($array)) { return false; } if (is_associative_array($array)) { ksort($array, $sort_flags); } else { sort($array, $sort_flags); } foreach ($array as &$value) { if (is_array($value)) { ksort_recursive($value, $sort_flags); } } return true; } /** * Convert snake_case to camelCase * * @param string $string Snake case string * @param bool $capitalize_first Whether to capitalize first letter * @return string Camel case string */ function snake_to_camel($string, $capitalize_first = false) { $result = str_replace('_', '', ucwords($string, '_')); if (!$capitalize_first) { $result = lcfirst($result); } return $result; } /** * Convert camelCase to snake_case * * @param string $string Camel case string * @return string Snake case string */ function camel_to_snake($string) { return strtolower(preg_replace('/[A-Z]/', '_$0', lcfirst($string))); } /** * Check if a value is "blank" (null, empty string, empty array, etc.) * * @param mixed $value Value to check * @return bool True if blank */ function is_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); } /** * Get nested array value using "dot" notation * * @param array $array Source array * @param string $key Dot notation key * @param mixed $default Default value if not found * @return mixed Value or default */ function array_get($array, $key, $default = null) { if (is_null($key)) { return $array; } if (isset($array[$key])) { return $array[$key]; } foreach (explode('.', $key) as $segment) { if (!is_array($array) || !array_key_exists($segment, $array)) { return $default; } $array = $array[$segment]; } return $array; } /** * Set nested array value using "dot" notation * * @param array $array Target [by reference] * @param string $key Dot notation key * @param mixed $value Value to set * @return void */ function array_set(&$array, $key, $value) { if (is_null($key)) { $array = $value; return; } $keys = explode('.', $key); while (count($keys) > 1) { $key = array_shift($keys); if (!isset($array[$key]) || !is_array($array[$key])) { $array[$key] = []; } $array = &$array[$key]; } $array[array_shift($keys)] = $value; } /** * Flatten a multi-dimensional array into a single level * * @param array $array Array to flatten * @param int $depth Maximum depth to flatten * @return array Flattened array */ function array_flatten($array, $depth = INF) { $result = []; foreach ($array as $item) { if (!is_array($item)) { $result[] = $item; } elseif ($depth === 1) { $result = array_merge($result, array_values($item)); } else { $result = array_merge($result, array_flatten($item, $depth - 1)); } } return $result; } /** * Get the first element of an array that matches a condition * * @param array $array Source array * @param callable|null $callback Optional filter callback * @param mixed $default Default value if not found * @return mixed First element or default */ function array_first($array, $callback = null, $default = null) { if (is_null($callback)) { if (empty($array)) { return $default; } foreach ($array as $item) { return $item; } } foreach ($array as $key => $value) { if ($callback($value, $key)) { return $value; } } return $default; } /** * Get the last element of an array that matches a condition * * @param array $array Source array * @param callable|null $callback Optional filter callback * @param mixed $default Default value if not found * @return mixed Last element or default */ function array_last($array, $callback = null, $default = null) { if (is_null($callback)) { return empty($array) ? $default : end($array); } return array_first(array_reverse($array, true), $callback, $default); } /** * Create a temporary directory * * @param string $prefix Directory prefix * @param string $base Base directory (defaults to sys temp) * @return string|false Path to created directory or false on failure */ function make_temp_directory($prefix = 'rsx_', $base = null) { if ($base === null) { $base = sys_get_temp_dir(); } $attempts = 10; while ($attempts-- > 0) { $path = $base . '/' . $prefix . random_hash(8); if (mkdir($path, 0755)) { return $path; } } return false; } /** * Get the current site or a specific attribute from the current site. * * @param string|null $key * @return Site|mixed|null */ // Not yet implemented // function site($key = null) // { // if (is_null($key)) { // return \App\RSpade\Core\Session\Session::get_site(); // } // // $current_site = \App\RSpade\Core\Session\Session::get_site(); // return $current_site?->getAttribute($key); // } /** * Generate a URL for a specific site. * This function allows generating cross-site URLs by temporarily switching context. * * @param int $site_id * @param string $route * @param array $parameters * @param bool $absolute * @return string */ // Not yet implemented // function site_route($site_id, $route, $parameters = [], $absolute = true) // { // $original_site_id = \App\RSpade\Core\Session\Session::get_site_id(); // // try { // // Temporarily switch to target site context // \App\RSpade\Core\Session\Session::set_site_id($site_id); // // // Generate the route with the target site context // $url = route($route, $parameters, $absolute); // // return $url; // // } finally { // // Always restore original site context // \App\RSpade\Core\Session\Session::set_site_id($original_site_id); // } // } /** * Execute a shell command with pretty console output formatting. * Shows the command in gray with a carat prefix, then the output in default color. * * @param string $command The shell command to execute * @param bool $real_time Whether to show output in real-time (true) or after completion (false) * @param bool $throw_on_error Whether to throw an exception on non-zero exit code * @return array Returns array with 'output', 'error', and 'exit_code' */ function shell_exec_pretty($command, $real_time = true, $throw_on_error = false) { // ANSI color codes $gray = "\033[90m"; $reset = "\033[0m"; $red = "\033[31m"; // Display the command being run echo $gray . '> ' . $command . $reset . PHP_EOL; if ($real_time) { // Use passthru() for real-time output without proc_open() pipe buffer issues // Redirect to temp file to capture output for return value $temp_file = storage_path('rsx-tmp/shell_exec_pretty_' . uniqid() . '.txt'); // Use script command wrapper to show real-time output AND capture to file // passthru() shows output but doesn't capture it, so we use tee to do both $full_command = "($command 2>&1) | tee " . escapeshellarg($temp_file); // passthru() displays output in real-time and returns the exit code via $exit_code passthru($full_command, $exit_code); // Read captured output from file $output = ''; $error = ''; if (file_exists($temp_file)) { $output = file_get_contents($temp_file); unlink($temp_file); // Clean up } } else { // Use shell_exec for simple execution $full_command = $command . ' 2>&1'; $output = shell_exec($full_command); $exit_code = 0; // shell_exec doesn't provide exit codes $error = ''; // Display output if ($output !== null) { echo $output; } } // Check exit code if ($exit_code !== 0 && $throw_on_error) { $error_msg = "Command failed with exit code $exit_code: $command"; if ($error) { $error_msg .= "\nError output: $error"; } throw new RuntimeException($error_msg); } return [ 'output' => trim($output), 'error' => trim($error), 'exit_code' => $exit_code, ]; } /** * Check if a command exists in the system PATH. * * @param string $command * @return bool */ function command_exists($command) { $which = PHP_OS_FAMILY === 'Windows' ? 'where' : 'which'; $result = shell_exec("$which $command 2>&1"); return !empty($result) && !str_contains($result, 'not found') && !str_contains($result, 'Could not find'); } /** * Execute command without exec()'s output truncation issues * Drop-in replacement for exec() using shell_exec() and file redirection * * exec() has a critical flaw: it reads command output line-by-line into an array, * which can hit memory/buffer limits on large outputs (>1MB typical), causing * SILENT TRUNCATION without throwing errors or exceptions. * * \exec_safe() uses shell_exec() internally which handles unlimited output without * pipe buffer truncation issues, while maintaining the exact same signature as exec(). * * Usage: * // Before: * exec('git status 2>&1', $output, $return_var); * * // After: * \exec_safe('git status 2>&1', $output, $return_var); * * @param string $command Command to execute * @param array &$output Output lines (populated by reference like exec()) * @param int &$return_var Return code (populated by reference like exec()) * @return string|false Last line of output, or false on failure (like exec()) */ function exec_safe(string $command, array &$output = [], int &$return_var = 0): string|false { // Use shell_exec() for reliable output capture without pipe buffer truncation // shell_exec() doesn't provide exit codes, so use exec() with file redirection for that $temp_file = storage_path('rsx-tmp/exec_safe_' . uniqid() . '.txt'); // Redirect output to temp file to get both output and exit code reliably $full_command = "($command) > " . escapeshellarg($temp_file) . " 2>&1; echo $?"; // Execute and capture just the exit code (last line) $result = shell_exec($full_command); $return_var = (int)trim($result); // Read the full output from file $combined = ''; if (file_exists($temp_file)) { $combined = file_get_contents($temp_file); unlink($temp_file); // Clean up } if ($combined === false) { $return_var = -1; $output = []; return false; } // Split into lines like exec() does $output = $combined ? explode("\n", trim($combined)) : []; // Return last line like exec() does return empty($output) ? '' : end($output); } /** * Debug dump and die - Enhanced var_dump/die() replacement * * Outputs debug information with file/line location, values, and stack trace. * This is the preferred method for temporary debugging output. * * @param mixed ...$values Any number of values to debug * @return void (never returns - calls die()) */ function rsx_dump_die(...$values) { \App\RSpade\Core\Debug\Debugger::rsx_dump_die(...$values); } /** * Get the RSX ID of the current view for use as a body class * * This is used in layout files to add the view's RSX ID as a CSS class * to the body tag, enabling view-specific styling. * * @return string The RSX ID or empty string if not set */ function rsx_body_class() { return \Illuminate\Support\Facades\View::shared('rsx_view_id', ''); } /** * Create an authentication required response * * Returns a special response object that Dispatch and Ajax_Endpoint handle differently: * - HTTP requests: Sets flash alert and redirects to login * - AJAX requests: Returns JSON error with success:false * * @param string $reason The reason authentication is required * @param string|null $redirect The URL to redirect to (default: /login) * @return \App\RSpade\Core\Response\Auth_Required_Response */ function response_auth_required($reason = 'Authentication Required', $redirect = '/login') { return new \App\RSpade\Core\Response\Auth_Required_Response($reason, $redirect); } /** * Create an unauthorized response * * Returns a special response object that Dispatch and Ajax_Endpoint handle differently: * - HTTP requests: Sets flash alert and redirects or throws exception * - AJAX requests: Returns JSON error with success:false * * @param string $reason The reason for unauthorized access * @param string|null $redirect The URL to redirect to (null throws exception) * @return \App\RSpade\Core\Response\Unauthorized_Response */ function response_unauthorized($reason = 'Unauthorized', $redirect = null) { return new \App\RSpade\Core\Response\Unauthorized_Response($reason, $redirect); } /** * Create a form error response * * Returns a special response object that Dispatch and Ajax_Endpoint handle differently: * - HTTP requests: Sets flash alert and redirects back to same URL as GET * - AJAX requests: Returns JSON error with success:false and details * * @param string $reason The error message * @param array $details Additional error details * @return \App\RSpade\Core\Response\Form_Error_Response */ function response_form_error($reason = 'An error has occurred.', $details = []) { return new \App\RSpade\Core\Response\Form_Error_Response($reason, $details); } /** * Create a fatal error response * * Returns a special response object that always throws an exception * in both HTTP and AJAX contexts * * @param string $reason The error message * @param array $details Additional error details * @return \App\RSpade\Core\Response\Fatal_Error_Response */ function response_fatal_error($reason = 'An error has occurred.', $details = []) { return new \App\RSpade\Core\Response\Fatal_Error_Response($reason, $details); } /** * Check if the current request is from a loopback IP address * * Returns true only if: * - Request is from localhost, 127.0.0.1, or ::1 (IPv6 loopback) * - No proxy headers (X-Real-IP, X-Forwarded-For) are present * * Used to ensure Playwright test headers can only be used from local connections. * * @return bool True if request is from loopback without proxy headers */ function is_loopback_ip(): bool { $request = request(); if (!$request) { return false; } // Check for proxy headers - if present, not a direct loopback connection if ($request->hasHeader('X-Real-IP') || $request->hasHeader('X-Forwarded-For') || $request->hasHeader('X-Forwarded-Host') || $request->hasHeader('X-Forwarded-Proto')) { return false; } // Get the client IP $ip = $request->ip(); // Check for loopback addresses // IPv4 loopback: 127.0.0.1 // IPv6 loopback: ::1 // Hostname: localhost $loopback_addresses = [ '127.0.0.1', '::1', 'localhost', ]; return in_array($ip, $loopback_addresses, true); } /** * Generate a hash for a file suitable for build/cache invalidation * * Uses different strategies based on environment: * - Development: Fast hash based on path, size, and modification time * - Production: Thorough hash based on path and file contents * * This function is used by the manifest and bundle systems to detect * when files have changed and need recompilation. * * @param string $file_path The absolute path to the file * @return string A hash string representing the file state */ function _rsx_file_hash_for_build($file_path) { if (!file_exists($file_path)) { shouldnt_happen("File does not exist for hashing: {$file_path}"); } // In development mode, use a fast hash based on metadata if (!app()->environment('production')) { $hash_data = [ 'path' => $file_path, 'size' => filesize($file_path), 'mtime' => filemtime($file_path), ]; return md5(json_encode($hash_data)); } // In production mode, use a thorough hash based on content $hash_data = [ 'path' => $file_path, 'content' => file_get_contents($file_path), ]; return hash('sha512', json_encode($hash_data)); } /** * Convert text to HTML preserving whitespace and indentation * * Converts plain text to HTML that displays with proper formatting: * - HTML special characters are escaped * - Leading spaces on each line are converted to   * - Newlines are converted to
tags * - Trailing whitespace on lines is trimmed * * This is useful for displaying source code or formatted text in HTML * where you want to preserve the indentation and line breaks. * * @param string $text The plain text to convert * @return string HTML-formatted text */ function text_to_html_with_whitespace(string $text): string { // First, escape HTML special characters to prevent XSS $text = htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); // Split into lines $lines = explode("\n", $text); // Process each line $processed_lines = []; foreach ($lines as $line) { // Trim trailing whitespace only (preserve leading spaces) $line = rtrim($line); // Count leading spaces $leading_spaces = strlen($line) - strlen(ltrim($line)); if ($leading_spaces > 0) { // Replace leading spaces with   $spaces_html = str_repeat(' ', $leading_spaces); $line = $spaces_html . substr($line, $leading_spaces); } $processed_lines[] = $line; } // Join lines with
\n return implode("
\n", $processed_lines); } /** * Normalize a path resolving . and .. components without resolving symlinks * * Unlike PHP's realpath(), this function normalizes paths by resolving . and .. * components but does NOT follow symlinks. This is important when working with * symlinked directories where you need the logical path, not the physical path. * * Behavior: * - Resolves . (current directory) and .. (parent directory) components * - Does NOT resolve symlinks (unlike realpath()) * - Converts relative paths to absolute by prepending base_path() * - Returns normalized absolute path, or false if file doesn't exist * * Examples: * - rsxrealpath('/var/www/html/system/rsx/foo/../bar') * => '/var/www/html/system/rsx/bar' * * - rsxrealpath('rsx/foo/../bar') * => '/var/www/html/rsx/foo/../bar' (after base_path prepend) * => '/var/www/html/rsx/bar' * * - If /var/www/html/system/rsx is a symlink to /var/www/html/rsx: * rsxrealpath('/var/www/html/system/rsx/bar') * => '/var/www/html/system/rsx/bar' (keeps symlink path, unlike realpath) * * @param string $path The path to normalize * @return string|false The normalized absolute path, or false if file doesn't exist */ function rsxrealpath(string $path): string|false { // Convert relative path to absolute if (!str_starts_with($path, '/')) { $path = base_path() . '/' . $path; } // Split path into components $parts = explode('/', $path); $result = []; foreach ($parts as $part) { if ($part === '' || $part === '.') { // Skip empty parts and current directory references continue; } elseif ($part === '..') { // Go up one directory (remove last component) if (!empty($result)) { array_pop($result); } } else { // Regular path component $result[] = $part; } } // Rebuild path with leading slash $normalized = '/' . implode('/', $result); // Check if path exists (like realpath does) if (!file_exists($normalized)) { return false; } return $normalized; }